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:
23
.env.example
23
.env.example
@@ -35,6 +35,29 @@ AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token
|
||||
# Get token from: https://t.me/BotFather
|
||||
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
||||
|
||||
# ========================================
|
||||
# Obsidian MCP Integration (Optional)
|
||||
# ========================================
|
||||
# Obsidian MCP server provides advanced vault operations via Obsidian REST API
|
||||
# See: OBSIDIAN_MCP_INTEGRATION.md for setup instructions
|
||||
|
||||
# Enable/disable Obsidian MCP integration
|
||||
OBSIDIAN_MCP_ENABLED=false
|
||||
|
||||
# Obsidian Local REST API Key
|
||||
# Install "Local REST API" plugin in Obsidian first, then generate key in settings
|
||||
OBSIDIAN_API_KEY=your-obsidian-api-key-here
|
||||
|
||||
# Obsidian REST API endpoint (default: http://127.0.0.1:27123)
|
||||
OBSIDIAN_BASE_URL=http://127.0.0.1:27123
|
||||
|
||||
# Path to your main Obsidian vault (overrides config/obsidian_mcp.yaml)
|
||||
# OBSIDIAN_VAULT_PATH=C:/Users/YourName/Documents/MyVault
|
||||
|
||||
# Tool routing strategy (optional, overrides config/obsidian_mcp.yaml)
|
||||
# Options: obsidian_preferred, custom_preferred, obsidian_only
|
||||
# OBSIDIAN_ROUTING_STRATEGY=obsidian_preferred
|
||||
|
||||
# ========================================
|
||||
# Alternative LLM Providers (Optional)
|
||||
# ========================================
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -42,6 +42,8 @@ Thumbs.db
|
||||
*.local.json
|
||||
.env
|
||||
.env.local
|
||||
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
||||
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
||||
|
||||
# Memory workspace (optional - remove if you want to version control)
|
||||
@@ -63,6 +65,12 @@ usage_data.json
|
||||
config/google_credentials.yaml
|
||||
config/google_oauth_token.json
|
||||
|
||||
# Obsidian MCP config (contains vault path - use obsidian_mcp.example.yaml)
|
||||
config/obsidian_mcp.yaml
|
||||
|
||||
# Gitea config (contains access token - use gitea_config.example.yaml)
|
||||
config/gitea_config.yaml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# Claude Agent SDK Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation integrates the Claude Agent SDK as the **default backend** for the Ajarbot LLM interface, replacing the previous pay-per-token API model with Claude Pro subscription-based access.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Strategy: Thin Wrapper (Strategy A)
|
||||
|
||||
The Agent SDK is implemented as a **pure LLM backend replacement**:
|
||||
- SDK handles only the LLM communication layer
|
||||
- Existing `agent.py` tool execution loop remains unchanged
|
||||
- All 17 tools (file operations, Gmail, Calendar, etc.) work identically
|
||||
- Zero changes required to `agent.py`, `tools.py`, or adapters
|
||||
|
||||
### Three Modes of Operation
|
||||
|
||||
The system now supports three modes (in priority order):
|
||||
|
||||
1. **Agent SDK Mode** (DEFAULT)
|
||||
- Uses Claude Pro subscription
|
||||
- No API token costs
|
||||
- Enabled by default when `claude-agent-sdk` is installed
|
||||
- Set `USE_AGENT_SDK=true` (default)
|
||||
|
||||
2. **Direct API Mode**
|
||||
- Pay-per-token using Anthropic API
|
||||
- Requires `ANTHROPIC_API_KEY`
|
||||
- Enable with `USE_DIRECT_API=true`
|
||||
|
||||
3. **Legacy Server Mode** (Deprecated)
|
||||
- Uses local FastAPI server wrapper
|
||||
- Enable with `USE_CLAUDE_CODE_SERVER=true`
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### 1. Async/Sync Bridge
|
||||
|
||||
The Agent SDK is async-native, but the bot uses synchronous interfaces. We bridge them using `anyio.from_thread.run()`:
|
||||
|
||||
```python
|
||||
# Synchronous chat_with_tools() calls async _agent_sdk_chat_with_tools()
|
||||
response = anyio.from_thread.run(
|
||||
self._agent_sdk_chat_with_tools,
|
||||
messages,
|
||||
tools,
|
||||
system,
|
||||
max_tokens
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Response Format Conversion
|
||||
|
||||
Agent SDK responses are converted to `anthropic.types.Message` format for compatibility:
|
||||
|
||||
```python
|
||||
def _convert_sdk_response_to_message(self, sdk_response: Dict[str, Any]) -> Message:
|
||||
"""Convert Agent SDK response to anthropic.types.Message format."""
|
||||
# Extracts:
|
||||
# - TextBlock for text content
|
||||
# - ToolUseBlock for tool_use blocks
|
||||
# - Usage information
|
||||
# Returns MessageLike object compatible with agent.py
|
||||
```
|
||||
|
||||
### 3. Backward Compatibility
|
||||
|
||||
All existing environment variables work:
|
||||
- `ANTHROPIC_API_KEY` - Still used for Direct API mode
|
||||
- `USE_CLAUDE_CODE_SERVER` - Legacy mode still supported
|
||||
- `CLAUDE_CODE_SERVER_URL` - Legacy server URL
|
||||
|
||||
New variables:
|
||||
- `USE_AGENT_SDK=true` - Enable Agent SDK (default)
|
||||
- `USE_DIRECT_API=true` - Force Direct API mode
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
cd c:\Users\fam1n\projects\ajarbot
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This installs:
|
||||
- `claude-agent-sdk>=0.1.0` - Agent SDK
|
||||
- `anyio>=4.0.0` - Async/sync bridging
|
||||
|
||||
### Step 2: Configure Mode (Optional)
|
||||
|
||||
Agent SDK is the default. To use a different mode:
|
||||
|
||||
**For Direct API (pay-per-token):**
|
||||
```bash
|
||||
# Add to .env
|
||||
USE_DIRECT_API=true
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**For Legacy Server:**
|
||||
```bash
|
||||
# Add to .env
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### Step 3: Run the Bot
|
||||
|
||||
```bash
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
[LLM] Using Claude Agent SDK (Pro subscription)
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `requirements.txt`
|
||||
- Replaced `claude-code-sdk` with `claude-agent-sdk`
|
||||
- Added `anyio>=4.0.0` for async bridging
|
||||
- Removed FastAPI/Uvicorn (no longer needed for default mode)
|
||||
|
||||
### 2. `llm_interface.py`
|
||||
Major refactoring:
|
||||
- Added Agent SDK import and availability check
|
||||
- New mode selection logic (agent_sdk > legacy_server > direct_api)
|
||||
- `_agent_sdk_chat()` - Async method for simple chat
|
||||
- `_agent_sdk_chat_with_tools()` - Async method for tool chat
|
||||
- `_convert_sdk_response_to_message()` - Response format converter
|
||||
- Updated `chat()` and `chat_with_tools()` with Agent SDK support
|
||||
|
||||
**Lines of code:**
|
||||
- Before: ~250 lines
|
||||
- After: ~410 lines
|
||||
- Added: ~160 lines for Agent SDK support
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Basic Functionality
|
||||
- [ ] Bot starts successfully with Agent SDK
|
||||
- [ ] Simple chat works (`agent.chat("Hello", "user")`)
|
||||
- [ ] Tool execution works (file operations, Gmail, Calendar)
|
||||
- [ ] Multiple tool calls in sequence work
|
||||
- [ ] Error handling works (invalid requests, SDK failures)
|
||||
|
||||
### Mode Switching
|
||||
- [ ] Agent SDK mode works (default)
|
||||
- [ ] Direct API mode works (`USE_DIRECT_API=true`)
|
||||
- [ ] Legacy server mode works (`USE_CLAUDE_CODE_SERVER=true`)
|
||||
- [ ] Fallback to Direct API when SDK unavailable
|
||||
|
||||
### Compatibility
|
||||
- [ ] All 17 tools work identically
|
||||
- [ ] Scheduled tasks work
|
||||
- [ ] Telegram adapter works
|
||||
- [ ] Slack adapter works
|
||||
- [ ] Memory system works
|
||||
- [ ] Self-healing system works
|
||||
|
||||
### Response Format
|
||||
- [ ] `.content` attribute accessible
|
||||
- [ ] `.stop_reason` attribute correct
|
||||
- [ ] `.usage` attribute present
|
||||
- [ ] TextBlock extraction works
|
||||
- [ ] ToolUseBlock extraction works
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Agent SDK not available, falling back to Direct API"
|
||||
|
||||
**Solution:** Install the SDK:
|
||||
```bash
|
||||
pip install claude-agent-sdk
|
||||
```
|
||||
|
||||
### Issue: SDK import fails
|
||||
|
||||
**Check:**
|
||||
1. Is `claude-agent-sdk` installed? (`pip list | grep claude-agent-sdk`)
|
||||
2. Is virtual environment activated?
|
||||
3. Are there any import errors in the SDK itself?
|
||||
|
||||
### Issue: Response format incompatible with agent.py
|
||||
|
||||
**Check:**
|
||||
- `MessageLike` class has all required attributes (`.content`, `.stop_reason`, `.usage`)
|
||||
- `TextBlock` and `ToolUseBlock` are properly constructed
|
||||
- `sdk_response` structure matches expected format
|
||||
|
||||
### Issue: Async/sync bridge errors
|
||||
|
||||
**Check:**
|
||||
- `anyio` is installed (`pip list | grep anyio`)
|
||||
- Thread context is available (not running in async context already)
|
||||
- No event loop conflicts
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Token Costs
|
||||
- **Agent SDK**: $0 (uses Pro subscription)
|
||||
- **Direct API**: ~$0.25-$1.25 per 1M tokens (Haiku), ~$3-$15 per 1M tokens (Sonnet)
|
||||
|
||||
### Speed
|
||||
- **Agent SDK**: Similar to Direct API
|
||||
- **Direct API**: Baseline
|
||||
- **Legacy Server**: Additional HTTP overhead
|
||||
|
||||
### Memory
|
||||
- **Agent SDK**: ~50MB overhead for SDK client
|
||||
- **Direct API**: Minimal overhead
|
||||
- **Legacy Server**: Requires separate server process
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
1. **Streaming Support**: Implement streaming responses via SDK
|
||||
2. **Better Error Messages**: More detailed SDK error propagation
|
||||
3. **Usage Tracking**: Track SDK usage separately (if SDK provides metrics)
|
||||
4. **Caching**: Implement prompt caching for Agent SDK (if supported)
|
||||
5. **Batch Requests**: Support batch processing via SDK
|
||||
|
||||
### Migration Path
|
||||
1. Phase 1: Agent SDK as default (DONE)
|
||||
2. Phase 2: Remove legacy server code (after testing period)
|
||||
3. Phase 3: Deprecate Direct API mode (after SDK proven stable)
|
||||
4. Phase 4: SDK-only implementation
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.0.0 (2026-02-15)
|
||||
- Initial Agent SDK implementation
|
||||
- Three-mode architecture (agent_sdk, direct_api, legacy_server)
|
||||
- Async/sync bridge using anyio
|
||||
- Response format converter
|
||||
- Backward compatibility with existing env vars
|
||||
- All 17 tools preserved
|
||||
- Zero changes to agent.py, tools.py, adapters
|
||||
|
||||
## References
|
||||
|
||||
- **Agent SDK Docs**: (TBD - add when available)
|
||||
- **Anthropic API Docs**: https://docs.anthropic.com/
|
||||
- **anyio Docs**: https://anyio.readthedocs.io/
|
||||
|
||||
## Credits
|
||||
|
||||
- **Implementation**: Strategy A (Thin Wrapper)
|
||||
- **Planning**: Based on planning agent recommendations
|
||||
- **Architecture**: Minimal disruption, maximum compatibility
|
||||
@@ -1,151 +0,0 @@
|
||||
# Hybrid Search Implementation Summary
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
Successfully upgraded Ajarbot's memory system from keyword-only search to **hybrid semantic + keyword search**.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Stack
|
||||
- **FastEmbed** (sentence-transformers/all-MiniLM-L6-v2) - 384-dimensional embeddings
|
||||
- **usearch** - Fast vector similarity search
|
||||
- **SQLite FTS5** - Keyword/BM25 search (retained)
|
||||
|
||||
### Scoring Algorithm
|
||||
- **0.7 weight** - Vector similarity (semantic understanding)
|
||||
- **0.3 weight** - BM25 score (keyword matching)
|
||||
- Combined and normalized for optimal results
|
||||
|
||||
### Performance
|
||||
- **Query time**: ~15ms average (was 5ms keyword-only)
|
||||
- **Storage overhead**: +1.5KB per memory chunk
|
||||
- **Cost**: $0 (runs locally, no API calls)
|
||||
- **Embeddings generated**: 59 for existing memories
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **memory_system.py**
|
||||
- Added FastEmbed and usearch imports
|
||||
- Initialize embedding model in `__init__` (line ~88)
|
||||
- Added `_generate_embedding()` method
|
||||
- Modified `index_file()` to generate and store embeddings
|
||||
- Implemented `search_hybrid()` method
|
||||
- Added database migration for `vector_id` column
|
||||
- Save vector index on `close()`
|
||||
|
||||
2. **agent.py**
|
||||
- Line 71: Changed `search()` to `search_hybrid()`
|
||||
|
||||
3. **memory_workspace/MEMORY.md**
|
||||
- Updated Core Stack section
|
||||
- Changed "Planned (Phase 2)" to "IMPLEMENTED"
|
||||
- Added Recent Changes entry
|
||||
- Updated Architecture Decisions
|
||||
|
||||
## Results - Before vs After
|
||||
|
||||
### Example Query: "How do I reduce costs?"
|
||||
|
||||
**Keyword Search (old)**:
|
||||
```
|
||||
No results found!
|
||||
```
|
||||
|
||||
**Hybrid Search (new)**:
|
||||
```
|
||||
1. MEMORY.md:28 (score: 0.228)
|
||||
## Cost Optimizations (2026-02-13)
|
||||
Target: Minimize API costs...
|
||||
|
||||
2. SOUL.md:45 (score: 0.213)
|
||||
Be proactive and use tools...
|
||||
```
|
||||
|
||||
### Example Query: "when was I born"
|
||||
|
||||
**Keyword Search (old)**:
|
||||
```
|
||||
No results found!
|
||||
```
|
||||
|
||||
**Hybrid Search (new)**:
|
||||
```
|
||||
1. SOUL.md:1 (score: 0.071)
|
||||
# SOUL - Agent Identity...
|
||||
|
||||
2. MEMORY.md:49 (score: 0.060)
|
||||
## Search Evolution...
|
||||
```
|
||||
|
||||
## How It Works Automatically
|
||||
|
||||
The bot now automatically uses hybrid search on **every chat message**:
|
||||
|
||||
1. User sends message to bot
|
||||
2. `agent.py` calls `memory.search_hybrid(user_message, max_results=2)`
|
||||
3. System generates embedding for query (~10ms)
|
||||
4. Searches vector index for semantic matches
|
||||
5. Searches FTS5 for keyword matches
|
||||
6. Combines scores (70% semantic, 30% keyword)
|
||||
7. Returns top 2 results
|
||||
8. Results injected into LLM context automatically
|
||||
|
||||
**No user action needed** - it's completely transparent!
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```bash
|
||||
pip install fastembed usearch
|
||||
```
|
||||
|
||||
Installs:
|
||||
- fastembed (0.7.4)
|
||||
- usearch (2.23.0)
|
||||
- numpy (2.4.2)
|
||||
- onnxruntime (1.24.1)
|
||||
- Plus supporting libraries
|
||||
|
||||
## Files Created
|
||||
|
||||
- `memory_workspace/vectors.usearch` - Vector index (~90KB for 59 vectors)
|
||||
- `test_hybrid_search.py` - Test script
|
||||
- `test_agent_hybrid.py` - Agent integration test
|
||||
- `demo_hybrid_comparison.py` - Comparison demo
|
||||
|
||||
## Memory Impact
|
||||
|
||||
- **FastEmbed model**: ~50MB RAM (loaded once, persists)
|
||||
- **Vector index**: ~1.5KB per memory chunk
|
||||
- **59 memories**: ~90KB total vector storage
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **10x better semantic recall** - Finds memories by meaning, not just keywords
|
||||
2. **Natural language queries** - "How do I save money?" finds cost optimization
|
||||
3. **Zero cost** - No API calls, runs entirely locally
|
||||
4. **Fast** - Sub-20ms queries
|
||||
5. **Automatic** - Works transparently in all bot interactions
|
||||
6. **Maintains keyword power** - Still finds exact technical terms
|
||||
|
||||
## Next Steps (Optional Future Enhancements)
|
||||
|
||||
- Add `search_user_hybrid()` for per-user semantic search
|
||||
- Tune weights (currently 0.7/0.3) based on query patterns
|
||||
- Add query expansion for better recall
|
||||
- Pre-compute common query embeddings for speed
|
||||
|
||||
## Verification
|
||||
|
||||
Run comparison test:
|
||||
```bash
|
||||
python demo_hybrid_comparison.py
|
||||
```
|
||||
|
||||
Output shows keyword search finding 0 results, hybrid finding relevant matches for all queries.
|
||||
|
||||
---
|
||||
|
||||
**Implementation Status**: ✅ COMPLETE
|
||||
**Date**: 2026-02-13
|
||||
**Lines of Code**: ~150 added to memory_system.py
|
||||
**Breaking Changes**: None (backward compatible)
|
||||
@@ -1,297 +0,0 @@
|
||||
# Claude Agent SDK Implementation - Summary
|
||||
|
||||
## Status: ✅ COMPLETE
|
||||
|
||||
The Claude Agent SDK backend has been successfully implemented in `llm_interface.py` following the "Strategy A - Thin Wrapper" approach from the planning phase.
|
||||
|
||||
## Implementation Overview
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`llm_interface.py`** (408 lines, +160 lines)
|
||||
- Added Agent SDK import and availability check
|
||||
- Implemented three-mode architecture (agent_sdk, direct_api, legacy_server)
|
||||
- Added async/sync bridge using `anyio.from_thread.run()`
|
||||
- Implemented SDK response to Message format converter
|
||||
- Preserved all existing functionality
|
||||
|
||||
2. **`requirements.txt`** (31 lines)
|
||||
- Replaced `claude-code-sdk` with `claude-agent-sdk>=0.1.0`
|
||||
- Added `anyio>=4.0.0` for async/sync bridging
|
||||
- Removed FastAPI/Uvicorn (no longer needed for default mode)
|
||||
|
||||
### Files Created
|
||||
|
||||
3. **`AGENT_SDK_IMPLEMENTATION.md`** (Documentation)
|
||||
- Architecture overview
|
||||
- Installation instructions
|
||||
- Testing checklist
|
||||
- Troubleshooting guide
|
||||
- Performance considerations
|
||||
|
||||
4. **`MIGRATION_GUIDE_AGENT_SDK.md`** (User guide)
|
||||
- Step-by-step migration instructions
|
||||
- Environment variable reference
|
||||
- Troubleshooting common issues
|
||||
- Rollback plan
|
||||
- FAQ section
|
||||
|
||||
5. **`test_agent_sdk.py`** (Test suite)
|
||||
- 5 comprehensive tests
|
||||
- Mode selection verification
|
||||
- Response format compatibility
|
||||
- Simple chat and tool chat tests
|
||||
- Initialization tests
|
||||
|
||||
6. **`IMPLEMENTATION_SUMMARY.md`** (This file)
|
||||
- Quick reference summary
|
||||
- Key features
|
||||
- Verification steps
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### ✅ Three-Mode Architecture
|
||||
|
||||
**Agent SDK Mode (DEFAULT)**
|
||||
- Uses Claude Pro subscription (zero API costs)
|
||||
- Automatic async/sync bridging
|
||||
- Full tool support (all 17 tools)
|
||||
- Enabled by default when SDK installed
|
||||
|
||||
**Direct API Mode**
|
||||
- Pay-per-token using Anthropic API
|
||||
- Usage tracking enabled
|
||||
- Prompt caching support
|
||||
- Fallback when SDK unavailable
|
||||
|
||||
**Legacy Server Mode (Deprecated)**
|
||||
- Backward compatible with old setup
|
||||
- Still functional but not recommended
|
||||
|
||||
### ✅ Async/Sync Bridge
|
||||
|
||||
```python
|
||||
# Synchronous interface calls async SDK methods
|
||||
response = anyio.from_thread.run(
|
||||
self._agent_sdk_chat_with_tools,
|
||||
messages, tools, system, max_tokens
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ Response Format Conversion
|
||||
|
||||
Converts Agent SDK responses to `anthropic.types.Message` format:
|
||||
- TextBlock for text content
|
||||
- ToolUseBlock for tool calls
|
||||
- Usage information
|
||||
- Full compatibility with agent.py
|
||||
|
||||
### ✅ Backward Compatibility
|
||||
|
||||
All existing features preserved:
|
||||
- Environment variables (ANTHROPIC_API_KEY, etc.)
|
||||
- Usage tracking (for Direct API mode)
|
||||
- Model switching (/sonnet, /haiku commands)
|
||||
- Prompt caching (for Direct API mode)
|
||||
- All 17 tools (file ops, Gmail, Calendar, etc.)
|
||||
- Scheduled tasks
|
||||
- Memory system
|
||||
- Self-healing system
|
||||
- All adapters (Telegram, Slack, etc.)
|
||||
|
||||
### ✅ Zero Changes Required
|
||||
|
||||
No modifications needed to:
|
||||
- `agent.py` - Tool execution loop unchanged
|
||||
- `tools.py` - All 17 tools work identically
|
||||
- `adapters/` - Telegram, Slack adapters unchanged
|
||||
- `memory_system.py` - Memory system unchanged
|
||||
- `self_healing.py` - Self-healing unchanged
|
||||
- `scheduled_tasks.py` - Scheduler unchanged
|
||||
|
||||
## Mode Selection Logic
|
||||
|
||||
```
|
||||
Priority Order:
|
||||
1. USE_DIRECT_API=true → Direct API mode
|
||||
2. USE_CLAUDE_CODE_SERVER=true → Legacy server mode
|
||||
3. USE_AGENT_SDK=true (default) → Agent SDK mode
|
||||
4. Agent SDK unavailable → Fallback to Direct API
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### New Variables
|
||||
- `USE_AGENT_SDK=true` (default) - Enable Agent SDK
|
||||
- `USE_DIRECT_API=true` - Force Direct API mode
|
||||
|
||||
### Preserved Variables
|
||||
- `ANTHROPIC_API_KEY` - For Direct API mode
|
||||
- `USE_CLAUDE_CODE_SERVER` - For legacy server mode
|
||||
- `CLAUDE_CODE_SERVER_URL` - Legacy server URL
|
||||
|
||||
## Code Statistics
|
||||
|
||||
### Lines of Code Added
|
||||
- `llm_interface.py`: ~160 lines
|
||||
- `test_agent_sdk.py`: ~450 lines
|
||||
- Documentation: ~800 lines
|
||||
- **Total: ~1,410 lines**
|
||||
|
||||
### Test Coverage
|
||||
- 5 automated tests
|
||||
- All test scenarios pass
|
||||
- Response format validated
|
||||
- Mode selection verified
|
||||
|
||||
## Installation & Usage
|
||||
|
||||
### Quick Start
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Run the bot (Agent SDK is default)
|
||||
python bot_runner.py
|
||||
|
||||
# Expected output:
|
||||
# [LLM] Using Claude Agent SDK (Pro subscription)
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
python test_agent_sdk.py
|
||||
|
||||
# Expected: 5/5 tests pass
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
### ✅ Implementation
|
||||
- [x] Agent SDK backend class implemented
|
||||
- [x] Async/sync bridge using anyio
|
||||
- [x] Response format converter
|
||||
- [x] Three-mode architecture
|
||||
- [x] Backward compatibility maintained
|
||||
- [x] Usage tracking preserved (for Direct API)
|
||||
- [x] Error handling implemented
|
||||
|
||||
### ✅ Testing
|
||||
- [x] Initialization test
|
||||
- [x] Simple chat test
|
||||
- [x] Chat with tools test
|
||||
- [x] Response format test
|
||||
- [x] Mode selection test
|
||||
|
||||
### ✅ Documentation
|
||||
- [x] Implementation guide created
|
||||
- [x] Migration guide created
|
||||
- [x] Test suite created
|
||||
- [x] Inline code comments
|
||||
- [x] Summary document created
|
||||
|
||||
### ✅ Compatibility
|
||||
- [x] agent.py unchanged
|
||||
- [x] tools.py unchanged
|
||||
- [x] adapters unchanged
|
||||
- [x] All 17 tools work
|
||||
- [x] Scheduled tasks work
|
||||
- [x] Memory system works
|
||||
- [x] Self-healing works
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Current Limitations
|
||||
1. **No streaming support** - SDK responses are not streamed (future enhancement)
|
||||
2. **No usage tracking for Agent SDK** - Only Direct API mode tracks usage
|
||||
3. **No prompt caching for Agent SDK** - Only Direct API mode supports caching
|
||||
4. **Mode changes require restart** - Cannot switch modes dynamically
|
||||
|
||||
### Future Enhancements
|
||||
1. Implement streaming responses via SDK
|
||||
2. Add SDK-specific usage metrics (if SDK provides them)
|
||||
3. Implement dynamic mode switching
|
||||
4. Add prompt caching support for Agent SDK
|
||||
5. Optimize response format conversion
|
||||
6. Add batch request support
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
### Cost (per 1M tokens)
|
||||
| Mode | Input | Output | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| Agent SDK | $0 | $0 | Uses Pro subscription |
|
||||
| Direct API (Haiku) | $0.25 | $1.25 | Pay-per-token |
|
||||
| Direct API (Sonnet) | $3.00 | $15.00 | Pay-per-token |
|
||||
|
||||
### Speed
|
||||
- **Agent SDK**: Similar to Direct API
|
||||
- **Direct API**: Baseline
|
||||
- **Legacy Server**: Slower (HTTP overhead)
|
||||
|
||||
### Memory
|
||||
- **Agent SDK**: ~50MB overhead for SDK client
|
||||
- **Direct API**: Minimal overhead
|
||||
- **Legacy Server**: Requires separate process
|
||||
|
||||
## Migration Impact
|
||||
|
||||
### Zero Disruption
|
||||
- Existing users can keep using Direct API mode
|
||||
- Legacy server mode still works
|
||||
- No breaking changes
|
||||
- Smooth migration path
|
||||
|
||||
### Recommended Migration
|
||||
1. Install new dependencies
|
||||
2. Let bot default to Agent SDK mode
|
||||
3. Verify all features work
|
||||
4. Remove old server code (optional)
|
||||
|
||||
### Rollback Plan
|
||||
If issues occur:
|
||||
1. Set `USE_DIRECT_API=true` in `.env`
|
||||
2. Restart bot
|
||||
3. Report issues for investigation
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### ✅ All Met
|
||||
- [x] Agent SDK is the default backend
|
||||
- [x] API mode still works (not Agent SDK default)
|
||||
- [x] Async/sync bridge functional
|
||||
- [x] Response format compatible with agent.py
|
||||
- [x] Backward compatibility with old env vars
|
||||
- [x] All existing functionality preserved
|
||||
- [x] Zero changes to agent.py, tools.py, adapters
|
||||
- [x] Test suite passes
|
||||
- [x] Documentation complete
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Claude Agent SDK implementation is **complete and production-ready**. The implementation follows the "Strategy A - Thin Wrapper" approach, making the SDK a pure LLM backend replacement while preserving all existing functionality.
|
||||
|
||||
### Key Achievements
|
||||
1. ✅ Agent SDK is the default mode
|
||||
2. ✅ Zero breaking changes
|
||||
3. ✅ All 17 tools work identically
|
||||
4. ✅ Comprehensive testing and documentation
|
||||
5. ✅ Smooth migration path with rollback option
|
||||
|
||||
### Next Steps
|
||||
1. Test in production environment
|
||||
2. Monitor for issues
|
||||
3. Gather user feedback
|
||||
4. Plan future enhancements (streaming, caching, etc.)
|
||||
5. Consider deprecating legacy server mode
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: 2026-02-15
|
||||
**Strategy Used**: Strategy A - Thin Wrapper
|
||||
**Files Modified**: 2 (llm_interface.py, requirements.txt)
|
||||
**Files Created**: 4 (docs + tests)
|
||||
**Total Lines Added**: ~1,410 lines
|
||||
**Breaking Changes**: 0
|
||||
**Tests Passing**: 5/5
|
||||
**Status**: ✅ PRODUCTION READY
|
||||
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
325
MIGRATION.md
325
MIGRATION.md
@@ -1,325 +0,0 @@
|
||||
# Migration Guide: FastAPI Server to Agent SDK
|
||||
|
||||
This guide helps you upgrade from the old FastAPI server setup to the new Claude Agent SDK integration.
|
||||
|
||||
## What Changed?
|
||||
|
||||
### Old Architecture (Deprecated)
|
||||
```
|
||||
Bot → FastAPI Server (localhost:8000) → Claude Code SDK → Claude
|
||||
```
|
||||
- Required running `claude_code_server.py` in separate terminal
|
||||
- Only worked with Pro subscription
|
||||
- More complex setup with multiple processes
|
||||
|
||||
### New Architecture (Current)
|
||||
```
|
||||
Bot → Claude Agent SDK → Claude (Pro OR API)
|
||||
```
|
||||
- Single process (no separate server)
|
||||
- Works with Pro subscription OR API key
|
||||
- Simpler setup and operation
|
||||
- Same functionality, less complexity
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Dependencies
|
||||
|
||||
Pull latest code and reinstall dependencies:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This installs `claude-agent-sdk` and removes deprecated dependencies.
|
||||
|
||||
### Step 2: Update Environment Configuration
|
||||
|
||||
Edit your `.env` file:
|
||||
|
||||
**Remove these deprecated variables:**
|
||||
```bash
|
||||
# DELETE THESE
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
**Add new mode selection:**
|
||||
```bash
|
||||
# ADD THIS
|
||||
AJARBOT_LLM_MODE=agent-sdk # Use Pro subscription (default)
|
||||
# OR
|
||||
AJARBOT_LLM_MODE=api # Use pay-per-token API
|
||||
```
|
||||
|
||||
**If using API mode**, ensure you have:
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**If using agent-sdk mode**, authenticate once:
|
||||
```bash
|
||||
claude auth login
|
||||
```
|
||||
|
||||
### Step 3: Stop Old Server
|
||||
|
||||
The FastAPI server is no longer needed:
|
||||
|
||||
1. Stop any running `claude_code_server.py` processes
|
||||
2. Remove it from startup scripts/systemd services
|
||||
3. Optionally archive or delete `claude_code_server.py` (kept for reference)
|
||||
|
||||
### Step 4: Use New Launcher
|
||||
|
||||
**Old way:**
|
||||
```bash
|
||||
# Terminal 1
|
||||
python claude_code_server.py
|
||||
|
||||
# Terminal 2
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
**New way:**
|
||||
```bash
|
||||
# Single command
|
||||
run.bat # Windows
|
||||
python ajarbot.py # Linux/Mac
|
||||
```
|
||||
|
||||
The new launcher:
|
||||
- Runs pre-flight checks (Node.js, authentication, config)
|
||||
- Sets sensible defaults (agent-sdk mode)
|
||||
- Starts bot in single process
|
||||
- No separate server needed
|
||||
|
||||
### Step 5: Test Your Setup
|
||||
|
||||
Run health check:
|
||||
```bash
|
||||
python ajarbot.py --health
|
||||
```
|
||||
|
||||
Expected output (agent-sdk mode):
|
||||
```
|
||||
============================================================
|
||||
Ajarbot Pre-Flight Checks
|
||||
============================================================
|
||||
|
||||
✓ Python 3.10.x
|
||||
✓ Node.js found: v18.x.x
|
||||
✓ Claude CLI authenticated
|
||||
|
||||
[Configuration Checks]
|
||||
✓ Config file found: config/adapters.local.yaml
|
||||
|
||||
Pre-flight checks complete!
|
||||
============================================================
|
||||
```
|
||||
|
||||
### Step 6: Verify Functionality
|
||||
|
||||
Test that everything works:
|
||||
|
||||
1. **Start the bot:**
|
||||
```bash
|
||||
run.bat # or python ajarbot.py
|
||||
```
|
||||
|
||||
2. **Send a test message** via Slack/Telegram
|
||||
|
||||
3. **Verify tools work:**
|
||||
- Ask bot to read a file
|
||||
- Request calendar events
|
||||
- Test scheduled tasks
|
||||
|
||||
All features are preserved:
|
||||
- 15 tools (file ops, Gmail, Calendar, Contacts)
|
||||
- Memory system with hybrid search
|
||||
- Multi-platform adapters
|
||||
- Task scheduling
|
||||
|
||||
## Mode Comparison
|
||||
|
||||
Choose the mode that fits your use case:
|
||||
|
||||
| Feature | Agent SDK Mode | API Mode |
|
||||
|---------|---------------|----------|
|
||||
| **Cost** | $20/month (Pro) | ~$0.25-$3/M tokens |
|
||||
| **Setup** | `claude auth login` | API key in `.env` |
|
||||
| **Requirements** | Node.js + Claude CLI | Just Python |
|
||||
| **Best For** | Personal heavy use | Light use, production |
|
||||
| **Rate Limits** | Pro subscription limits | API rate limits |
|
||||
|
||||
### Switching Between Modes
|
||||
|
||||
You can switch anytime by editing `.env`:
|
||||
|
||||
```bash
|
||||
# Switch to agent-sdk
|
||||
AJARBOT_LLM_MODE=agent-sdk
|
||||
|
||||
# Switch to API
|
||||
AJARBOT_LLM_MODE=api
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
No code changes needed - just restart the bot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Node.js not found" (Agent SDK mode)
|
||||
|
||||
**Option 1: Install Node.js**
|
||||
```bash
|
||||
# Download from https://nodejs.org
|
||||
# Or via package manager:
|
||||
winget install OpenJS.NodeJS # Windows
|
||||
brew install node # Mac
|
||||
sudo apt install nodejs # Ubuntu/Debian
|
||||
```
|
||||
|
||||
**Option 2: Switch to API mode**
|
||||
```bash
|
||||
# In .env
|
||||
AJARBOT_LLM_MODE=api
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
### "Claude CLI not authenticated"
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
claude auth status
|
||||
|
||||
# Re-authenticate
|
||||
claude auth logout
|
||||
claude auth login
|
||||
```
|
||||
|
||||
If Claude CLI isn't installed, download from: https://claude.ai/download
|
||||
|
||||
### "Agent SDK not available"
|
||||
|
||||
```bash
|
||||
pip install claude-agent-sdk
|
||||
```
|
||||
|
||||
If installation fails, use API mode instead.
|
||||
|
||||
### Old Environment Variables Still Set
|
||||
|
||||
Check your `.env` file for deprecated variables:
|
||||
|
||||
```bash
|
||||
# These should NOT be in your .env:
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
USE_AGENT_SDK=true
|
||||
USE_DIRECT_API=true
|
||||
```
|
||||
|
||||
Delete them and use `AJARBOT_LLM_MODE` instead.
|
||||
|
||||
### Bot Works But Features Missing
|
||||
|
||||
Ensure you have latest code:
|
||||
```bash
|
||||
git pull
|
||||
pip install -r requirements.txt --upgrade
|
||||
```
|
||||
|
||||
All features from the old setup are preserved:
|
||||
- Tools system (15 tools)
|
||||
- Memory with hybrid search
|
||||
- Scheduled tasks
|
||||
- Google integration
|
||||
- Multi-platform adapters
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**Agent SDK mode:**
|
||||
- May hit Pro subscription rate limits
|
||||
- Temporary solution: Switch to API mode
|
||||
- Long-term: Wait for limit reset (usually 24 hours)
|
||||
|
||||
**API mode:**
|
||||
- Check usage with: `python -c "from usage_tracker import UsageTracker; UsageTracker().print_summary()"`
|
||||
- Costs shown in usage_data.json
|
||||
- Default Haiku model is very cheap (~$0.04/day moderate use)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you need to rollback to the old setup:
|
||||
|
||||
1. **Restore old .env settings:**
|
||||
```bash
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
2. **Start the old server:**
|
||||
```bash
|
||||
python claude_code_server.py
|
||||
```
|
||||
|
||||
3. **Run bot with old method:**
|
||||
```bash
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
However, the new setup is recommended - same functionality with less complexity.
|
||||
|
||||
## What's Backward Compatible?
|
||||
|
||||
All existing functionality is preserved:
|
||||
|
||||
- Configuration files (`config/adapters.local.yaml`, `config/scheduled_tasks.yaml`)
|
||||
- Memory database (`memory_workspace/memory.db`)
|
||||
- User profiles (`memory_workspace/users/`)
|
||||
- Google OAuth tokens (`config/google_oauth_token.json`)
|
||||
- Tool definitions and capabilities
|
||||
- Adapter integrations
|
||||
|
||||
You can safely migrate without losing data or functionality.
|
||||
|
||||
## Benefits of New Setup
|
||||
|
||||
1. **Simpler operation**: Single command to start
|
||||
2. **Flexible modes**: Choose Pro subscription OR API
|
||||
3. **Automatic checks**: Pre-flight validation before starting
|
||||
4. **Better errors**: Clear messages about missing requirements
|
||||
5. **Less complexity**: No multi-process coordination
|
||||
6. **Same features**: All 15 tools, adapters, scheduling preserved
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Review [CLAUDE_CODE_SETUP.md](CLAUDE_CODE_SETUP.md) for detailed mode documentation
|
||||
- Check [README.md](README.md) for quick start guides
|
||||
- Run `python ajarbot.py --health` to diagnose issues
|
||||
- Open an issue if you encounter problems
|
||||
|
||||
## Summary
|
||||
|
||||
**Before:**
|
||||
```bash
|
||||
# Terminal 1
|
||||
python claude_code_server.py
|
||||
|
||||
# Terminal 2
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
**After:**
|
||||
```bash
|
||||
# .env
|
||||
AJARBOT_LLM_MODE=agent-sdk # or "api"
|
||||
|
||||
# Single command
|
||||
run.bat # Windows
|
||||
python ajarbot.py # Linux/Mac
|
||||
```
|
||||
|
||||
Same features, less complexity, more flexibility.
|
||||
@@ -1,401 +0,0 @@
|
||||
# Migration Guide: Agent SDK Implementation
|
||||
|
||||
## Quick Start (TL;DR)
|
||||
|
||||
### For New Users
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Run the bot (Agent SDK is the default)
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
### For Existing Users
|
||||
```bash
|
||||
# 1. Update dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. That's it! The bot will automatically use Agent SDK
|
||||
# Your existing .env settings are preserved
|
||||
```
|
||||
|
||||
## Detailed Migration Steps
|
||||
|
||||
### Step 1: Understand Your Current Setup
|
||||
|
||||
Check your `.env` file:
|
||||
|
||||
```bash
|
||||
cat .env
|
||||
```
|
||||
|
||||
**Scenario A: Using Direct API (Pay-per-token)**
|
||||
```env
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
# No USE_CLAUDE_CODE_SERVER variable, or it's set to false
|
||||
```
|
||||
|
||||
**Scenario B: Using Legacy Claude Code Server**
|
||||
```env
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### Step 2: Choose Your Migration Path
|
||||
|
||||
#### Option 1: Migrate to Agent SDK (Recommended)
|
||||
|
||||
**Benefits:**
|
||||
- Uses Claude Pro subscription (no per-token costs)
|
||||
- Same speed as Direct API
|
||||
- No separate server process required
|
||||
- All features work identically
|
||||
|
||||
**Steps:**
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Update `.env` (optional - SDK is default):
|
||||
```env
|
||||
# Remove or comment out old settings
|
||||
# USE_CLAUDE_CODE_SERVER=false
|
||||
# CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Agent SDK is enabled by default, but you can be explicit:
|
||||
USE_AGENT_SDK=true
|
||||
|
||||
# Keep your API key for fallback (optional)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
3. Run the bot:
|
||||
```bash
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
4. Verify Agent SDK is active:
|
||||
```
|
||||
[LLM] Using Claude Agent SDK (Pro subscription)
|
||||
```
|
||||
|
||||
#### Option 2: Keep Using Direct API
|
||||
|
||||
**When to use:**
|
||||
- You don't have Claude Pro subscription
|
||||
- You prefer pay-per-token billing
|
||||
- You need to track exact API usage costs
|
||||
|
||||
**Steps:**
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Update `.env`:
|
||||
```env
|
||||
USE_DIRECT_API=true
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
3. Run the bot:
|
||||
```bash
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
4. Verify Direct API is active:
|
||||
```
|
||||
[LLM] Using Direct API (pay-per-token)
|
||||
```
|
||||
|
||||
#### Option 3: Keep Using Legacy Server (Not Recommended)
|
||||
|
||||
**Only use if:**
|
||||
- You have a custom modified `claude_code_server.py`
|
||||
- You need the server for other tools/integrations
|
||||
|
||||
**Steps:**
|
||||
1. Keep your current setup
|
||||
2. The legacy server mode still works
|
||||
3. No changes required
|
||||
|
||||
### Step 3: Test the Migration
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
python test_agent_sdk.py
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
=== Test 1: LLMInterface Initialization ===
|
||||
✓ LLMInterface created successfully
|
||||
- Mode: agent_sdk
|
||||
...
|
||||
Total: 5/5 tests passed
|
||||
🎉 All tests passed!
|
||||
```
|
||||
|
||||
### Step 4: Verify Bot Functionality
|
||||
|
||||
Test all critical features:
|
||||
|
||||
1. **Simple Chat:**
|
||||
```
|
||||
User: Hello!
|
||||
Bot: [Should respond normally]
|
||||
```
|
||||
|
||||
2. **Tool Usage:**
|
||||
```
|
||||
User: What files are in the current directory?
|
||||
Bot: [Should use list_directory tool]
|
||||
```
|
||||
|
||||
3. **Gmail Integration:**
|
||||
```
|
||||
User: Check my recent emails
|
||||
Bot: [Should use read_emails tool]
|
||||
```
|
||||
|
||||
4. **Calendar Integration:**
|
||||
```
|
||||
User: What's on my calendar today?
|
||||
Bot: [Should use read_calendar tool]
|
||||
```
|
||||
|
||||
5. **Scheduled Tasks:**
|
||||
- Verify scheduled tasks still run
|
||||
- Check `config/scheduled_tasks.yaml`
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### New Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `USE_AGENT_SDK` | `true` | Enable Agent SDK mode (default) |
|
||||
| `USE_DIRECT_API` | `false` | Force Direct API mode |
|
||||
|
||||
### Existing Variables (Still Supported)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ANTHROPIC_API_KEY` | - | API key for Direct API mode |
|
||||
| `USE_CLAUDE_CODE_SERVER` | `false` | Enable legacy server mode |
|
||||
| `CLAUDE_CODE_SERVER_URL` | `http://localhost:8000` | Legacy server URL |
|
||||
|
||||
### Priority Order
|
||||
|
||||
If multiple modes are enabled, the priority is:
|
||||
1. `USE_DIRECT_API=true` → Direct API mode
|
||||
2. `USE_CLAUDE_CODE_SERVER=true` → Legacy server mode
|
||||
3. `USE_AGENT_SDK=true` (default) → Agent SDK mode
|
||||
4. Agent SDK unavailable → Fallback to Direct API mode
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Agent SDK not available, falling back to Direct API"
|
||||
|
||||
**Cause:** `claude-agent-sdk` is not installed
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
pip install claude-agent-sdk
|
||||
```
|
||||
|
||||
### Issue: "ModuleNotFoundError: No module named 'claude_agent_sdk'"
|
||||
|
||||
**Cause:** Package not in requirements.txt or not installed
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify requirements.txt has claude-agent-sdk
|
||||
grep claude-agent-sdk requirements.txt
|
||||
|
||||
# If missing, update requirements.txt
|
||||
pip install claude-agent-sdk anyio
|
||||
|
||||
# Or reinstall all dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Issue: Bot still using Direct API after migration
|
||||
|
||||
**Cause:** Explicit `USE_DIRECT_API=true` in `.env`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Edit .env and remove or change to false
|
||||
USE_DIRECT_API=false
|
||||
|
||||
# Or comment out the line
|
||||
# USE_DIRECT_API=true
|
||||
```
|
||||
|
||||
### Issue: "anyio" import error
|
||||
|
||||
**Cause:** `anyio` package not installed (required for async/sync bridge)
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
pip install anyio>=4.0.0
|
||||
```
|
||||
|
||||
### Issue: Response format errors in agent.py
|
||||
|
||||
**Cause:** SDK response not properly converted to Message format
|
||||
|
||||
**Solution:**
|
||||
1. Check `_convert_sdk_response_to_message()` implementation
|
||||
2. Verify `TextBlock` and `ToolUseBlock` are imported
|
||||
3. Run `python test_agent_sdk.py` to verify format compatibility
|
||||
|
||||
### Issue: Tool execution fails with Agent SDK
|
||||
|
||||
**Cause:** Agent SDK might not be returning expected tool format
|
||||
|
||||
**Solution:**
|
||||
1. Check `_agent_sdk_chat_with_tools()` method
|
||||
2. Verify tool definitions are passed correctly
|
||||
3. Add debug logging:
|
||||
```python
|
||||
print(f"SDK Response: {sdk_response}")
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you need to rollback to the old system:
|
||||
|
||||
### Rollback to Direct API
|
||||
|
||||
```env
|
||||
# In .env
|
||||
USE_DIRECT_API=true
|
||||
USE_AGENT_SDK=false
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
### Rollback to Legacy Server
|
||||
|
||||
```env
|
||||
# In .env
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Start the server
|
||||
python claude_code_server.py
|
||||
```
|
||||
|
||||
### Rollback Code (if needed)
|
||||
|
||||
```bash
|
||||
# Reinstall old dependencies (FastAPI/Uvicorn)
|
||||
pip install fastapi>=0.109.0 uvicorn>=0.27.0
|
||||
|
||||
# Revert to old requirements.txt (backup needed)
|
||||
git checkout HEAD~1 requirements.txt
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Q: Will this increase my costs?
|
||||
|
||||
**A:** If you have Claude Pro, **costs will decrease to $0** for LLM calls. If you don't have Pro, you can keep using Direct API mode.
|
||||
|
||||
### Q: Will this break my existing bot setup?
|
||||
|
||||
**A:** No. All functionality is preserved:
|
||||
- All 17 tools work identically
|
||||
- Scheduled tasks unchanged
|
||||
- Adapters (Telegram, Slack) unchanged
|
||||
- Memory system unchanged
|
||||
- Self-healing system unchanged
|
||||
|
||||
### Q: Can I switch modes dynamically?
|
||||
|
||||
**A:** Not currently. You need to set the mode in `.env` and restart the bot.
|
||||
|
||||
### Q: Will usage tracking still work?
|
||||
|
||||
**A:** Usage tracking is disabled for Agent SDK mode (no costs to track). It still works for Direct API mode.
|
||||
|
||||
### Q: What about prompt caching?
|
||||
|
||||
**A:** Prompt caching currently works only in Direct API mode. Agent SDK support may be added in the future.
|
||||
|
||||
### Q: Can I use different modes for different bot instances?
|
||||
|
||||
**A:** Yes! Each bot instance reads `.env` independently. You can run multiple bots with different modes.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
Use this checklist to ensure a smooth migration:
|
||||
|
||||
### Pre-Migration
|
||||
- [ ] Backup `.env` file
|
||||
- [ ] Backup `requirements.txt`
|
||||
- [ ] Note current mode (Direct API or Legacy Server)
|
||||
- [ ] Verify bot is working correctly
|
||||
- [ ] Document any custom configurations
|
||||
|
||||
### Migration
|
||||
- [ ] Update `requirements.txt` (or `git pull` latest)
|
||||
- [ ] Install new dependencies (`pip install -r requirements.txt`)
|
||||
- [ ] Update `.env` with new variables (if needed)
|
||||
- [ ] Remove old variables (if migrating from legacy server)
|
||||
|
||||
### Testing
|
||||
- [ ] Run `python test_agent_sdk.py`
|
||||
- [ ] Test simple chat
|
||||
- [ ] Test tool usage (file operations)
|
||||
- [ ] Test Gmail integration (if using)
|
||||
- [ ] Test Calendar integration (if using)
|
||||
- [ ] Test scheduled tasks
|
||||
- [ ] Test with Telegram adapter (if using)
|
||||
- [ ] Test with Slack adapter (if using)
|
||||
|
||||
### Post-Migration
|
||||
- [ ] Verify mode in startup logs (`[LLM] Using Claude Agent SDK...`)
|
||||
- [ ] Monitor for errors in first 24 hours
|
||||
- [ ] Verify scheduled tasks still run
|
||||
- [ ] Check memory system working correctly
|
||||
- [ ] Document any issues or edge cases
|
||||
|
||||
### Cleanup (Optional)
|
||||
- [ ] Remove unused legacy server code (if not needed)
|
||||
- [ ] Remove `USE_CLAUDE_CODE_SERVER` from `.env`
|
||||
- [ ] Uninstall FastAPI/Uvicorn (if not used elsewhere)
|
||||
- [ ] Update documentation with new setup
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. **Check logs:** Look for `[LLM]` and `[Agent]` prefixed messages
|
||||
2. **Run tests:** `python test_agent_sdk.py`
|
||||
3. **Check mode:** Verify startup message shows correct mode
|
||||
4. **Verify dependencies:** `pip list | grep claude-agent-sdk`
|
||||
5. **Check .env:** Ensure no conflicting variables
|
||||
|
||||
## Next Steps
|
||||
|
||||
After successful migration:
|
||||
|
||||
1. **Monitor performance:** Compare speed and response quality
|
||||
2. **Track savings:** Calculate cost savings vs Direct API
|
||||
3. **Report issues:** Document any bugs or edge cases
|
||||
4. **Optimize:** Look for opportunities to leverage SDK features
|
||||
5. **Share feedback:** Help improve the implementation
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.0.0 (2026-02-15)
|
||||
- Initial Agent SDK implementation
|
||||
- Three-mode architecture
|
||||
- Backward compatibility maintained
|
||||
- Zero changes to agent.py, tools.py, adapters
|
||||
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Quick Setup: Obsidian Local REST API Plugin
|
||||
|
||||
## Your Current Status
|
||||
- ✅ Obsidian is running
|
||||
- ✅ Config file is ready (`config/obsidian_mcp.yaml`)
|
||||
- ❌ Local REST API plugin not responding on port 27123
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Install the Local REST API Plugin in Obsidian
|
||||
|
||||
1. Open **Obsidian**
|
||||
2. Go to **Settings** (gear icon) → **Community Plugins**
|
||||
3. If you see "Safe mode is on", click **Turn off Safe Mode**
|
||||
4. Click **Browse** button
|
||||
5. Search for: **"Local REST API"**
|
||||
6. Click **Install** on the "Local REST API" plugin by coddingtonbear
|
||||
7. After installation, click **Enable**
|
||||
|
||||
### 2. Configure the Plugin
|
||||
|
||||
1. In Obsidian Settings, scroll down to **Plugin Options**
|
||||
2. Find **Local REST API** in the left sidebar
|
||||
3. Copy your API key shown in the plugin settings
|
||||
4. Compare it with the key in your `config/obsidian_mcp.yaml`:
|
||||
```
|
||||
api_key: "ee625f06a778e3267a9219f9b8c1065a039375ea270e414a34436c6a3027f2da"
|
||||
```
|
||||
5. If they don't match, update the config file with the correct key
|
||||
|
||||
### 3. Verify the Plugin is Running
|
||||
|
||||
1. Check that the plugin shows as **enabled** in Obsidian
|
||||
2. The plugin should show: "Server running on http://127.0.0.1:27123"
|
||||
3. Restart Obsidian if needed
|
||||
|
||||
### 4. Test the Connection
|
||||
|
||||
Run this command in your project directory:
|
||||
|
||||
```powershell
|
||||
python -c "from obsidian_mcp import check_obsidian_health; print('Health Check:', check_obsidian_health(force=True))"
|
||||
```
|
||||
|
||||
**Expected output**: `Health Check: True`
|
||||
|
||||
### 5. Restart the Bot
|
||||
|
||||
```powershell
|
||||
venv\Scripts\activate
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
Look for this line in the startup logs:
|
||||
```
|
||||
[LLM] Obsidian MCP server registered (8 tools)
|
||||
```
|
||||
|
||||
If you see this instead, the plugin isn't working yet:
|
||||
```
|
||||
[LLM] Obsidian MCP enabled but health check failed - using custom tools only
|
||||
```
|
||||
|
||||
## Alternative: File-Based Access (Already Working)
|
||||
|
||||
If you don't want to use the Local REST API plugin, your bot can **already** access your Obsidian vault via the filesystem using these tools:
|
||||
|
||||
- `fleeting_note` - Quick capture with auto-ID
|
||||
- `daily_note` - Timestamped journal entries
|
||||
- `literature_note` - Save web articles
|
||||
- `permanent_note` - Create refined notes with auto-linking
|
||||
- `search_vault` - Hybrid semantic search
|
||||
- `search_by_tags` - Find notes by tags
|
||||
- `read_file` / `write_file` / `edit_file` - Direct file access
|
||||
|
||||
The **Obsidian MCP tools** add these extra capabilities:
|
||||
- `obsidian_update_note` - Frontmatter-aware editing
|
||||
- `obsidian_global_search` - Native Obsidian search
|
||||
- `obsidian_manage_frontmatter` - Advanced metadata management
|
||||
- `obsidian_manage_tags` - Bulk tag operations
|
||||
- `obsidian_delete_note` - Safe deletion
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin shows "Server not running"
|
||||
- Click the **Restart Server** button in the plugin settings
|
||||
- Check Windows Firewall isn't blocking port 27123
|
||||
|
||||
### API key mismatch
|
||||
- Copy the EXACT key from Obsidian plugin settings
|
||||
- Update `config/obsidian_mcp.yaml` → `connection.api_key`
|
||||
|
||||
### Wrong vault path
|
||||
- Your current vault path: `C:/Users/fam1n/OneDrive/Documents/Remote-Mind-Vault`
|
||||
- Verify this path exists and contains a `.obsidian` folder
|
||||
|
||||
### Health check still fails after setup
|
||||
- Restart Obsidian
|
||||
- Restart the bot
|
||||
- Check port 27123 isn't used by another program:
|
||||
```powershell
|
||||
netstat -ano | findstr :27123
|
||||
```
|
||||
@@ -139,11 +139,37 @@ class AdapterRuntime:
|
||||
if adapter:
|
||||
await adapter.send_typing_indicator(message.channel_id)
|
||||
|
||||
# Capture the event loop for thread-safe progress updates
|
||||
event_loop = asyncio.get_running_loop()
|
||||
|
||||
# Create progress callback to send updates to the user
|
||||
def progress_callback(update_message: str):
|
||||
"""Send progress updates to the user during long operations."""
|
||||
if adapter:
|
||||
try:
|
||||
# Create outbound message for progress update
|
||||
progress_msg = OutboundMessage(
|
||||
platform=message.platform,
|
||||
channel_id=message.channel_id,
|
||||
text=update_message,
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
# Run async send in a thread-safe way
|
||||
# Use the captured event loop instead of get_running_loop()
|
||||
# since this callback runs from a thread (agent.chat via to_thread)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
adapter.send_message(progress_msg),
|
||||
event_loop
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Failed to send progress update: {e}")
|
||||
|
||||
# Get response from agent (synchronous call in thread)
|
||||
response = await asyncio.to_thread(
|
||||
self.agent.chat,
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Apply postprocessors
|
||||
@@ -217,6 +243,14 @@ class AdapterRuntime:
|
||||
print("[Runtime] Starting adapter runtime...")
|
||||
await self.registry.start_all()
|
||||
|
||||
# Pass the main event loop to the LLM interface so that Agent SDK
|
||||
# async calls (from worker threads created by asyncio.to_thread)
|
||||
# can be scheduled back onto this loop via run_coroutine_threadsafe.
|
||||
loop = asyncio.get_running_loop()
|
||||
if hasattr(self.agent, 'llm') and hasattr(self.agent.llm, 'set_event_loop'):
|
||||
self.agent.llm.set_event_loop(loop)
|
||||
print("[Runtime] Event loop reference passed to LLM interface")
|
||||
|
||||
self._is_running = True
|
||||
self.message_loop_task = asyncio.create_task(
|
||||
self._process_message_queue()
|
||||
|
||||
193
agent.py
193
agent.py
@@ -1,7 +1,8 @@
|
||||
"""AI Agent with Memory and LLM Integration."""
|
||||
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
import time
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
from hooks import HooksSystem
|
||||
from llm_interface import LLMInterface
|
||||
@@ -35,6 +36,8 @@ class Agent:
|
||||
self.conversation_history: List[dict] = []
|
||||
self._chat_lock = threading.Lock()
|
||||
self.healing_system = SelfHealingSystem(self.memory, self)
|
||||
self._progress_callback: Optional[Callable[[str], None]] = None
|
||||
self._progress_timer: Optional[threading.Timer] = None
|
||||
|
||||
# Sub-agent orchestration
|
||||
self.is_sub_agent = is_sub_agent
|
||||
@@ -194,13 +197,26 @@ class Agent:
|
||||
|
||||
self.conversation_history = self.conversation_history[start_idx:]
|
||||
|
||||
def chat(self, user_message: str, username: str = "default") -> str:
|
||||
def chat(
|
||||
self,
|
||||
user_message: str,
|
||||
username: str = "default",
|
||||
progress_callback: Optional[Callable[[str], None]] = None
|
||||
) -> str:
|
||||
"""Chat with context from memory and tool use.
|
||||
|
||||
Thread-safe: uses a lock to prevent concurrent modification of
|
||||
conversation history from multiple threads (e.g., scheduled tasks
|
||||
and live messages).
|
||||
|
||||
Args:
|
||||
user_message: The user's message
|
||||
username: The user's name (default: "default")
|
||||
progress_callback: Optional callback for sending progress updates during long operations
|
||||
"""
|
||||
# Store the callback for use during the chat
|
||||
self._progress_callback = progress_callback
|
||||
|
||||
# Handle model switching commands (no lock needed, read-only on history)
|
||||
if user_message.lower().startswith("/model "):
|
||||
model_name = user_message[7:].strip()
|
||||
@@ -225,48 +241,160 @@ class Agent:
|
||||
)
|
||||
|
||||
with self._chat_lock:
|
||||
try:
|
||||
return self._chat_inner(user_message, username)
|
||||
finally:
|
||||
# Clear the callback when done
|
||||
self._progress_callback = None
|
||||
|
||||
def _chat_inner(self, user_message: str, username: str) -> str:
|
||||
"""Inner chat logic, called while holding _chat_lock."""
|
||||
# Use specialist prompt if this is a sub-agent, otherwise use full context
|
||||
def _build_system_prompt(self, user_message: str, username: str) -> str:
|
||||
"""Build the system prompt with SOUL, user profile, and memory context."""
|
||||
if self.specialist_prompt:
|
||||
# Sub-agent: Use focused specialist prompt
|
||||
system = (
|
||||
return (
|
||||
f"{self.specialist_prompt}\n\n"
|
||||
f"You have access to {len(TOOL_DEFINITIONS)} tools. Use them to accomplish your specialized task. "
|
||||
f"Stay focused on your specialty and complete the task efficiently."
|
||||
f"You have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services. "
|
||||
f"Use them to accomplish your specialized task efficiently."
|
||||
)
|
||||
else:
|
||||
# Main agent: Use full SOUL, user profile, and memory context
|
||||
|
||||
soul = self.memory.get_soul()
|
||||
user_profile = self.memory.get_user(username)
|
||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=5)
|
||||
|
||||
memory_lines = [f"- {mem['snippet']}" for mem in relevant_memory]
|
||||
system = (
|
||||
return (
|
||||
f"{soul}\n\nUser Profile:\n{user_profile}\n\n"
|
||||
f"Relevant Memory:\n" + "\n".join(memory_lines) +
|
||||
f"\n\nYou have access to {len(TOOL_DEFINITIONS)} tools for file operations, "
|
||||
f"command execution, and Google services. Use them freely to help the user. "
|
||||
f"Note: You're running on a flat-rate Agent SDK subscription, so don't worry "
|
||||
f"about API costs when making multiple tool calls or processing large contexts."
|
||||
f"\n\nYou have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services (Gmail, Calendar, Contacts). "
|
||||
f"Use them freely to help the user."
|
||||
)
|
||||
|
||||
def _chat_inner(self, user_message: str, username: str) -> str:
|
||||
"""Inner chat logic, called while holding _chat_lock."""
|
||||
system = self._build_system_prompt(user_message, username)
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "user", "content": user_message}
|
||||
)
|
||||
|
||||
# Prune history to prevent unbounded growth
|
||||
self._prune_conversation_history()
|
||||
|
||||
# Tool execution loop
|
||||
# In Agent SDK mode, query() handles tool calls automatically via MCP.
|
||||
# The tool loop is only needed for Direct API mode.
|
||||
if self.llm.mode == "agent_sdk":
|
||||
return self._chat_agent_sdk(user_message, system)
|
||||
else:
|
||||
return self._chat_direct_api(user_message, system)
|
||||
|
||||
def _send_progress_update(self, elapsed_seconds: int):
|
||||
"""Send a progress update if callback is set."""
|
||||
if self._progress_callback:
|
||||
messages = [
|
||||
f"⏳ Still working... ({elapsed_seconds}s elapsed)",
|
||||
f"🔄 Processing your request... ({elapsed_seconds}s)",
|
||||
f"⚙️ Working on it, this might take a moment... ({elapsed_seconds}s)",
|
||||
]
|
||||
# Rotate through messages
|
||||
message = messages[(elapsed_seconds // 90) % len(messages)]
|
||||
try:
|
||||
self._progress_callback(message)
|
||||
except Exception as e:
|
||||
print(f"[Agent] Failed to send progress update: {e}")
|
||||
|
||||
def _start_progress_updates(self):
|
||||
"""Start periodic progress updates (every 90 seconds)."""
|
||||
def send_update(elapsed: int):
|
||||
self._send_progress_update(elapsed)
|
||||
# Schedule next update
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[elapsed + 90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
# Send first update after 90 seconds
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
def _stop_progress_updates(self):
|
||||
"""Stop progress updates."""
|
||||
if self._progress_timer:
|
||||
self._progress_timer.cancel()
|
||||
self._progress_timer = None
|
||||
|
||||
def _chat_agent_sdk(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Agent SDK. Tools are handled automatically by MCP."""
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
# Start progress updates
|
||||
self._start_progress_updates()
|
||||
|
||||
try:
|
||||
# chat_with_tools() in Agent SDK mode returns a string directly.
|
||||
# The SDK handles all tool calls via MCP servers internally.
|
||||
response = self.llm.chat_with_tools(
|
||||
context_messages,
|
||||
tools=[], # Ignored in Agent SDK mode; tools come from MCP
|
||||
system=system,
|
||||
)
|
||||
except TimeoutError as e:
|
||||
error_msg = "⏱️ Task timed out after 5 minutes. The task might be too complex - try breaking it into smaller steps."
|
||||
print(f"[Agent] TIMEOUT: {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response (TIMEOUT)",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"error_type": "timeout",
|
||||
},
|
||||
)
|
||||
return error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Agent SDK error: {e}"
|
||||
print(f"[Agent] {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
},
|
||||
)
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
finally:
|
||||
# Always stop progress updates when done
|
||||
self._stop_progress_updates()
|
||||
|
||||
# In Agent SDK mode, response is always a string
|
||||
final_response = response if isinstance(response, str) else str(response)
|
||||
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
# Write compact summary to memory
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=None # SDK handles tools internally; we don't track them here
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
def _chat_direct_api(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Direct API with manual tool execution loop."""
|
||||
max_iterations = MAX_TOOL_ITERATIONS
|
||||
# Enable caching for Sonnet to save 90% on repeated system prompts
|
||||
use_caching = "sonnet" in self.llm.model.lower()
|
||||
tools_used = []
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
# Get recent messages, ensuring we don't break tool_use/tool_result pairs
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
try:
|
||||
@@ -281,19 +409,17 @@ class Agent:
|
||||
print(f"[Agent] {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_inner",
|
||||
intent="Calling LLM API for chat response",
|
||||
component="agent.py:_chat_direct_api",
|
||||
intent="Calling Direct API for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"iteration": iteration,
|
||||
},
|
||||
)
|
||||
return f"Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
|
||||
# Check stop reason
|
||||
if response.stop_reason == "end_turn":
|
||||
# Extract text response
|
||||
text_content = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
@@ -301,7 +427,6 @@ class Agent:
|
||||
|
||||
final_response = "\n".join(text_content)
|
||||
|
||||
# Handle empty response
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
@@ -309,17 +434,16 @@ class Agent:
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
preview = final_response[:MEMORY_RESPONSE_PREVIEW_LENGTH]
|
||||
self.memory.write_memory(
|
||||
f"**{username}**: {user_message}\n"
|
||||
f"**Garvis**: {preview}...",
|
||||
daily=True,
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=tools_used if tools_used else None
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
elif response.stop_reason == "tool_use":
|
||||
# Build assistant message with tool uses
|
||||
assistant_content = []
|
||||
tool_uses = []
|
||||
|
||||
@@ -343,11 +467,11 @@ class Agent:
|
||||
"content": assistant_content
|
||||
})
|
||||
|
||||
# Execute tools and build tool result message
|
||||
tool_results = []
|
||||
for tool_use in tool_uses:
|
||||
if tool_use.name not in tools_used:
|
||||
tools_used.append(tool_use.name)
|
||||
result = execute_tool(tool_use.name, tool_use.input, healing_system=self.healing_system)
|
||||
# Truncate large tool outputs to prevent token explosion
|
||||
if len(result) > 5000:
|
||||
result = result[:5000] + "\n... (output truncated)"
|
||||
print(f"[Tool] {tool_use.name}: {result[:100]}...")
|
||||
@@ -363,7 +487,6 @@ class Agent:
|
||||
})
|
||||
|
||||
else:
|
||||
# Unexpected stop reason
|
||||
return f"Unexpected stop reason: {response.stop_reason}"
|
||||
|
||||
return "Error: Maximum tool use iterations exceeded"
|
||||
|
||||
22
config/gitea_config.example.yaml
Normal file
22
config/gitea_config.example.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Gitea Configuration
|
||||
# Copy to gitea_config.yaml and fill in your values
|
||||
#
|
||||
# cp config/gitea_config.example.yaml config/gitea_config.yaml
|
||||
|
||||
# Gitea instance URL (no trailing slash)
|
||||
base_url: "https://vulcan.apophisnetworking.net"
|
||||
|
||||
# Personal Access Token for API authentication
|
||||
# To generate a token:
|
||||
# 1. Go to https://vulcan.apophisnetworking.net/user/settings/applications
|
||||
# 2. Under "Manage Access Tokens", enter a token name (e.g., "garvis-bot")
|
||||
# 3. Select permissions: at minimum, check "repo" (read) scope
|
||||
# 4. Click "Generate Token"
|
||||
# 5. Copy the token here (it is shown only once!)
|
||||
token: "your_personal_access_token_here"
|
||||
|
||||
# Default repository owner (used when repo is not specified in tool calls)
|
||||
default_owner: "jramos"
|
||||
|
||||
# Default repository name (used when repo is not specified in tool calls)
|
||||
default_repo: "homelab"
|
||||
113
config/obsidian_mcp.example.yaml
Normal file
113
config/obsidian_mcp.example.yaml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Obsidian MCP Server Configuration
|
||||
# ===================================
|
||||
# This file configures the external Obsidian MCP server integration.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Copy this file: copy config\obsidian_mcp.example.yaml config\obsidian_mcp.yaml
|
||||
# 2. Set your vault_path below
|
||||
# 3. Ensure Node.js 20+ is installed: node --version
|
||||
# 4. Restart the bot: python bot_runner.py
|
||||
#
|
||||
# The config file (obsidian_mcp.yaml) is gitignored to protect your vault path.
|
||||
# See OBSIDIAN_MCP_INTEGRATION.md for full documentation.
|
||||
|
||||
obsidian_mcp:
|
||||
# ---- Core Settings ----
|
||||
|
||||
# Enable or disable the Obsidian MCP integration
|
||||
# Set to false to disable without removing the config
|
||||
enabled: true
|
||||
|
||||
# Absolute path to your Obsidian vault directory
|
||||
# This MUST be the root folder of your vault (contains .obsidian/ subfolder)
|
||||
#
|
||||
# Windows examples (use double backslashes OR forward slashes):
|
||||
# "C:\\Users\\username\\Documents\\Obsidian\\MyVault"
|
||||
# "C:/Users/username/Documents/Obsidian/MyVault"
|
||||
#
|
||||
# Linux/Mac example:
|
||||
# "/home/username/obsidian-vault"
|
||||
#
|
||||
# To use the bot's built-in zettelkasten vault (same files as custom tools):
|
||||
# "C:\\Users\\username\\projects\\ajarbot\\memory_workspace\\obsidian"
|
||||
vault_path: "C:\\Users\\YOUR_USERNAME\\Documents\\Obsidian\\YOUR_VAULT"
|
||||
|
||||
# ---- Server Settings ----
|
||||
|
||||
server:
|
||||
# Command to launch the MCP server
|
||||
# Default: "npx" (downloads obsidian-mcp on first run)
|
||||
# Alternative: "node" (if installed globally)
|
||||
command: "npx"
|
||||
|
||||
# Arguments passed to the command
|
||||
# The vault_path is appended automatically as the last argument
|
||||
# Default: ["-y", "obsidian-mcp"]
|
||||
# -y = auto-confirm npm package installation
|
||||
args: ["-y", "obsidian-mcp"]
|
||||
|
||||
# Server startup timeout in seconds
|
||||
# Increase if npx is slow on first download
|
||||
startup_timeout: 30
|
||||
|
||||
# ---- Permission Controls ----
|
||||
# Control which operations the bot is allowed to perform.
|
||||
# Disable categories to restrict the bot's access to your vault.
|
||||
|
||||
permissions:
|
||||
# Read operations (safe, no changes to vault)
|
||||
# Tools: read-note, search-vault, list-available-vaults, manage-tags
|
||||
allow_read: true
|
||||
|
||||
# Write operations (creates new files or modifies existing ones)
|
||||
# Tools: create-note, edit-note
|
||||
allow_write: true
|
||||
|
||||
# Delete operations (permanently removes files)
|
||||
# Tools: delete-note
|
||||
# DISABLED by default for safety - enable only if you trust the bot
|
||||
allow_delete: false
|
||||
|
||||
# Move/rename operations (changes file paths)
|
||||
# Tools: move-note
|
||||
allow_move: true
|
||||
|
||||
# Tag operations (modifies YAML frontmatter)
|
||||
# Tools: add-tags, remove-tags, rename-tag
|
||||
allow_tags: true
|
||||
|
||||
# Directory operations (creates new folders)
|
||||
# Tools: create-directory
|
||||
allow_directories: true
|
||||
|
||||
# ---- Safety Settings ----
|
||||
|
||||
safety:
|
||||
# Require user confirmation before any write operation
|
||||
# When true, the bot will ask "Are you sure?" before creating/editing notes
|
||||
confirm_writes: false
|
||||
|
||||
# Create a backup copy before deleting a note
|
||||
# Backup is saved to .trash/ in the vault root
|
||||
backup_before_delete: true
|
||||
|
||||
# Maximum note content size in characters
|
||||
# Prevents accidental creation of very large notes
|
||||
max_note_size: 50000
|
||||
|
||||
# Directories the bot should never access or modify
|
||||
# Paths are relative to the vault root
|
||||
restricted_paths:
|
||||
- ".obsidian" # Obsidian app configuration
|
||||
- ".trash" # Obsidian trash folder
|
||||
- ".git" # Git repository data (if vault is version-controlled)
|
||||
|
||||
# ---- Environment Variable Overrides ----
|
||||
# These environment variables override the YAML settings above.
|
||||
# Add them to your .env file if you prefer not to use YAML config.
|
||||
#
|
||||
# OBSIDIAN_VAULT_PATH=C:\Users\username\Documents\Obsidian\MyVault
|
||||
# OBSIDIAN_MCP_ENABLED=true
|
||||
# OBSIDIAN_MCP_COMMAND=npx
|
||||
# OBSIDIAN_MCP_ALLOW_DELETE=false
|
||||
# OBSIDIAN_MCP_CONFIRM_WRITES=false
|
||||
@@ -5,13 +5,23 @@ tasks:
|
||||
# Morning briefing - sent to Slack/Telegram
|
||||
- name: morning-weather
|
||||
prompt: |
|
||||
Check the user profile (Jordan.md) for the location (Centennial, CO). Use the get_weather tool with OpenWeatherMap API to fetch the current weather. Format the report as:
|
||||
Check the user profile (Jordan.md) for the location (Centennial, CO). Use the get_weather tool with OpenWeatherMap API to fetch the current weather.
|
||||
|
||||
Also use web_fetch to get today's high/low from a weather service:
|
||||
https://wttr.in/Centennial,CO?format=j1
|
||||
|
||||
Parse the JSON response to extract:
|
||||
- maxtempF (today's high)
|
||||
- mintempF (today's low)
|
||||
|
||||
Format the report as:
|
||||
|
||||
🌤️ **Weather Report for Centennial, CO**
|
||||
- Current: [current]°F
|
||||
- High: [high]°F
|
||||
- Low: [low]°F
|
||||
- Current: [current]°F (feels like [feels_like]°F)
|
||||
- Today's High: [high]°F
|
||||
- Today's Low: [low]°F
|
||||
- Conditions: [conditions]
|
||||
- Wind: [wind speed] mph
|
||||
- Recommendation: [brief clothing/activity suggestion]
|
||||
|
||||
Keep it brief and friendly!
|
||||
@@ -20,6 +30,35 @@ tasks:
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "8088983654" # Your Telegram user ID
|
||||
|
||||
# Daily Zettelkasten Review
|
||||
- name: zettelkasten-daily-review
|
||||
prompt: |
|
||||
Time for your daily zettelkasten review! Help Jordan process fleeting notes:
|
||||
|
||||
1. Use search_by_tags to find all notes tagged with "fleeting"
|
||||
2. Show Jordan the list of fleeting notes captured today/recently
|
||||
3. For each note, ask: "Would you like to:
|
||||
a) Process this into a permanent note
|
||||
b) Keep as fleeting for now
|
||||
c) Delete (not useful)"
|
||||
|
||||
Format:
|
||||
📝 **Daily Zettelkasten Review**
|
||||
|
||||
You have [X] fleeting notes to review:
|
||||
|
||||
1. [Title] - [first line of content]
|
||||
2. [Title] - [first line of content]
|
||||
...
|
||||
|
||||
Reply with the number to process, or 'skip' to review later.
|
||||
|
||||
Keep it conversational and low-pressure!
|
||||
schedule: "daily 20:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "8088983654"
|
||||
|
||||
# Daily API cost report
|
||||
- name: daily-cost-report
|
||||
prompt: |
|
||||
|
||||
5
gitea_tools/__init__.py
Normal file
5
gitea_tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Gitea Tools - Private Gitea repository access for ajarbot."""
|
||||
|
||||
from .client import GiteaClient
|
||||
|
||||
__all__ = ["GiteaClient"]
|
||||
597
gitea_tools/client.py
Normal file
597
gitea_tools/client.py
Normal file
@@ -0,0 +1,597 @@
|
||||
"""Gitea API Client - Access private Gitea repositories.
|
||||
|
||||
Uses Gitea's REST API (compatible with GitHub API v3) to read files,
|
||||
list directories, search code, and get directory trees from private repos.
|
||||
|
||||
Authentication via Personal Access Token configured in config/gitea_config.yaml.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Config file path
|
||||
_CONFIG_PATH = Path("config/gitea_config.yaml")
|
||||
|
||||
# Request timeout (seconds)
|
||||
_REQUEST_TIMEOUT = 10.0
|
||||
|
||||
# Maximum file size to return (1MB)
|
||||
_MAX_FILE_SIZE = 1_000_000
|
||||
|
||||
# Maximum output characters (prevents token explosion)
|
||||
_MAX_OUTPUT_CHARS = 5000
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for Gitea REST API with Personal Access Token authentication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
default_owner: Optional[str] = None,
|
||||
default_repo: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialize Gitea client.
|
||||
|
||||
Args:
|
||||
base_url: Gitea instance URL (e.g., "https://vulcan.apophisnetworking.net").
|
||||
token: Personal Access Token for authentication.
|
||||
default_owner: Default repository owner (e.g., "jramos").
|
||||
default_repo: Default repository name (e.g., "homelab").
|
||||
|
||||
If arguments are not provided, reads from config/gitea_config.yaml.
|
||||
"""
|
||||
config = self._load_config()
|
||||
|
||||
self.base_url = (base_url or config.get("base_url", "")).rstrip("/")
|
||||
self.token = token or config.get("token", "")
|
||||
self.default_owner = default_owner or config.get("default_owner", "")
|
||||
self.default_repo = default_repo or config.get("default_repo", "")
|
||||
|
||||
if not self.base_url:
|
||||
raise ValueError(
|
||||
"Gitea base_url not configured. "
|
||||
"Set it in config/gitea_config.yaml or pass base_url argument."
|
||||
)
|
||||
if not self.token:
|
||||
raise ValueError(
|
||||
"Gitea token not configured. "
|
||||
"Create a Personal Access Token at "
|
||||
f"{self.base_url}/user/settings/applications "
|
||||
"and add it to config/gitea_config.yaml"
|
||||
)
|
||||
|
||||
self.api_url = f"{self.base_url}/api/v1"
|
||||
|
||||
logger.info(
|
||||
"[Gitea] Client initialized: %s (default: %s/%s)",
|
||||
self.base_url,
|
||||
self.default_owner,
|
||||
self.default_repo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from YAML file."""
|
||||
if not _CONFIG_PATH.exists():
|
||||
logger.warning(
|
||||
"[Gitea] Config file not found: %s. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml",
|
||||
_CONFIG_PATH,
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
content = _CONFIG_PATH.read_text(encoding="utf-8")
|
||||
config = yaml.safe_load(content) or {}
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error("[Gitea] Failed to load config: %s", e)
|
||||
return {}
|
||||
|
||||
def _parse_repo(
|
||||
self,
|
||||
repo: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""Parse owner/repo from various input formats.
|
||||
|
||||
Args:
|
||||
repo: Repository in "owner/repo" format, or just "repo" name.
|
||||
owner: Explicit owner (overrides repo string parsing).
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo) strings.
|
||||
"""
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
parsed_owner = parts[0]
|
||||
parsed_repo = parts[1]
|
||||
else:
|
||||
parsed_owner = owner or self.default_owner
|
||||
parsed_repo = repo or self.default_repo
|
||||
|
||||
if owner:
|
||||
parsed_owner = owner
|
||||
|
||||
if not parsed_owner or not parsed_repo:
|
||||
raise ValueError(
|
||||
f"Repository not specified. Provide repo as 'owner/repo' "
|
||||
f"or configure default_owner/default_repo in gitea_config.yaml. "
|
||||
f"Got owner='{parsed_owner}', repo='{parsed_repo}'"
|
||||
)
|
||||
|
||||
return parsed_owner, parsed_repo
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
"""Build request headers with authentication."""
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Garvis/1.0 (Ajarbot Gitea Integration)",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an authenticated API request.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.).
|
||||
endpoint: API endpoint path (e.g., "/repos/jramos/homelab/contents/README.md").
|
||||
params: Optional query parameters.
|
||||
|
||||
Returns:
|
||||
Dict with "success" key and either "data" or "error".
|
||||
"""
|
||||
url = f"{self.api_url}{endpoint}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
verify=True,
|
||||
headers=self._headers(),
|
||||
) as client:
|
||||
response = await client.request(method, url, params=params)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Authentication failed (HTTP 401). "
|
||||
"Check your Personal Access Token in config/gitea_config.yaml. "
|
||||
f"Generate a new token at: {self.base_url}/user/settings/applications"
|
||||
),
|
||||
}
|
||||
elif response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Not found (HTTP 404): {endpoint}",
|
||||
}
|
||||
elif response.status_code >= 400:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"HTTP {response.status_code}: {response.text[:200]}",
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
return {"success": True, "data": data}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Request to {self.base_url} timed out after {_REQUEST_TIMEOUT}s",
|
||||
}
|
||||
except httpx.ConnectError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Connection failed to {self.base_url}: {e}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Request failed: {str(e)}",
|
||||
}
|
||||
|
||||
async def get_file_content(
|
||||
self,
|
||||
file_path: str,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get raw file content from a repository.
|
||||
|
||||
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{filepath}
|
||||
|
||||
Args:
|
||||
file_path: Path to file in repo (e.g., "scripts/proxmox_collector.py").
|
||||
owner: Repository owner (default: from config).
|
||||
repo: Repository name or "owner/repo" (default: from config).
|
||||
branch: Branch name (default: repo default branch).
|
||||
|
||||
Returns:
|
||||
Dict with "success", and either "content"/"metadata" or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Normalize file path (remove leading slash)
|
||||
file_path = file_path.lstrip("/")
|
||||
|
||||
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{file_path}"
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
result = await self._request("GET", endpoint, params=params)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
data = result["data"]
|
||||
|
||||
# Handle case where path is a directory (returns a list)
|
||||
if isinstance(data, list):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"'{file_path}' is a directory, not a file. "
|
||||
f"Use gitea_list_files to browse directories."
|
||||
),
|
||||
}
|
||||
|
||||
# Check file size
|
||||
file_size = data.get("size", 0)
|
||||
if file_size > _MAX_FILE_SIZE:
|
||||
return {
|
||||
"success": True,
|
||||
"content": (
|
||||
f"[File too large: {file_size:,} bytes ({file_size / 1024 / 1024:.1f} MB). "
|
||||
f"Maximum is {_MAX_FILE_SIZE:,} bytes. "
|
||||
f"Use the download URL to fetch it directly.]"
|
||||
),
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"download_url": data.get("download_url", ""),
|
||||
"sha": data.get("sha", ""),
|
||||
},
|
||||
}
|
||||
|
||||
# Decode base64 content
|
||||
encoded_content = data.get("content", "")
|
||||
try:
|
||||
content = base64.b64decode(encoded_content).decode("utf-8")
|
||||
except (UnicodeDecodeError, Exception):
|
||||
return {
|
||||
"success": True,
|
||||
"content": "[Binary file - cannot display as text]",
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"encoding": data.get("encoding", ""),
|
||||
"download_url": data.get("download_url", ""),
|
||||
},
|
||||
}
|
||||
|
||||
# Truncate if too long
|
||||
truncated = False
|
||||
if len(content) > _MAX_OUTPUT_CHARS:
|
||||
content = content[:_MAX_OUTPUT_CHARS] + "\n\n... (file truncated)"
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": content,
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"sha": data.get("sha", ""),
|
||||
"last_commit_sha": data.get("last_commit_sha", ""),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"truncated": truncated,
|
||||
},
|
||||
}
|
||||
|
||||
async def list_files(
|
||||
self,
|
||||
path: str = "",
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""List files and directories at a path in the repository.
|
||||
|
||||
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{path}
|
||||
|
||||
Args:
|
||||
path: Directory path in repo (e.g., "scripts/"). Empty for root.
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
branch: Branch name.
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "files" list or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Normalize path
|
||||
path = path.strip("/")
|
||||
|
||||
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{path}" if path else f"/repos/{parsed_owner}/{parsed_repo}/contents"
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
result = await self._request("GET", endpoint, params=params)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
data = result["data"]
|
||||
|
||||
# If it's a single file (not a directory), inform the user
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"'{path}' is a file, not a directory. "
|
||||
f"Use gitea_read_file to read file contents."
|
||||
),
|
||||
}
|
||||
|
||||
# Build file listing
|
||||
files = []
|
||||
for item in data:
|
||||
entry = {
|
||||
"name": item.get("name", ""),
|
||||
"type": item.get("type", ""), # "file" or "dir"
|
||||
"path": item.get("path", ""),
|
||||
"size": item.get("size", 0),
|
||||
}
|
||||
files.append(entry)
|
||||
|
||||
# Sort: directories first, then files, alphabetically
|
||||
files.sort(key=lambda f: (0 if f["type"] == "dir" else 1, f["name"].lower()))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"files": files,
|
||||
"path": path or "/",
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"count": len(files),
|
||||
}
|
||||
|
||||
async def search_code(
|
||||
self,
|
||||
query: str,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for code in a repository.
|
||||
|
||||
Uses Gitea Code Search API: GET /repos/{owner}/{repo}/topics (fallback)
|
||||
or the general search: GET /repos/search
|
||||
|
||||
Note: Gitea's code search depends on indexer configuration.
|
||||
Falls back to repo-level search if code search is unavailable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "results" or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Try Gitea's code search endpoint first
|
||||
# GET /repos/{owner}/{repo}/contents - search by traversing
|
||||
# Gitea doesn't have a direct per-repo code search API like GitHub
|
||||
# Use the global code search with repo filter
|
||||
endpoint = "/repos/search"
|
||||
params = {
|
||||
"q": query,
|
||||
"owner": parsed_owner,
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
# First try: global code search (if Gitea has it enabled)
|
||||
code_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/grep"
|
||||
code_params = {"query": query}
|
||||
|
||||
# Gitea doesn't have a git grep API, use the topic/label search
|
||||
# or fall back to listing + content search
|
||||
# Best approach: use the Gitea search API
|
||||
search_endpoint = "/repos/search"
|
||||
search_params = {
|
||||
"q": query,
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
# For code search, Gitea's best option is the global search endpoint
|
||||
# with topic filter. But for actual file content search, we need to
|
||||
# traverse the tree and search file contents.
|
||||
# Use a pragmatic approach: get the repo tree and search filenames
|
||||
# and provide useful results.
|
||||
|
||||
# Strategy: Get flat tree, filter by query in filename and path
|
||||
tree_result = await self.get_tree(
|
||||
owner=parsed_owner,
|
||||
repo=parsed_repo,
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
if not tree_result["success"]:
|
||||
return tree_result
|
||||
|
||||
entries = tree_result.get("entries", [])
|
||||
query_lower = query.lower()
|
||||
|
||||
# Search filenames and paths
|
||||
matches = []
|
||||
for entry in entries:
|
||||
path = entry.get("path", "")
|
||||
if query_lower in path.lower():
|
||||
matches.append({
|
||||
"path": path,
|
||||
"type": entry.get("type", ""),
|
||||
"size": entry.get("size", 0),
|
||||
"match_type": "filename",
|
||||
})
|
||||
|
||||
# Limit results
|
||||
matches = matches[:20]
|
||||
|
||||
if not matches:
|
||||
return {
|
||||
"success": True,
|
||||
"results": [],
|
||||
"query": query,
|
||||
"message": (
|
||||
f"No files matching '{query}' found in "
|
||||
f"{parsed_owner}/{parsed_repo}. "
|
||||
f"Note: This searches file/directory names only. "
|
||||
f"For content search, read specific files with gitea_read_file."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": matches,
|
||||
"query": query,
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"count": len(matches),
|
||||
}
|
||||
|
||||
async def get_tree(
|
||||
self,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the directory tree of a repository.
|
||||
|
||||
Uses Gitea Git Trees API: GET /repos/{owner}/{repo}/git/trees/{sha}
|
||||
|
||||
Args:
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
branch: Branch name (default: repo default branch).
|
||||
recursive: If True, get full recursive tree.
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "entries" list or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# First, get the branch SHA (or default branch)
|
||||
ref = branch or "main"
|
||||
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||
branch_result = await self._request("GET", branch_endpoint)
|
||||
|
||||
if not branch_result["success"]:
|
||||
# Try "master" as fallback
|
||||
if not branch and "404" in branch_result.get("error", ""):
|
||||
ref = "master"
|
||||
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||
branch_result = await self._request("GET", branch_endpoint)
|
||||
|
||||
if not branch_result["success"]:
|
||||
return branch_result
|
||||
|
||||
branch_data = branch_result["data"]
|
||||
tree_sha = branch_data.get("commit", {}).get("id", "")
|
||||
|
||||
if not tree_sha:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not get tree SHA for branch '{ref}'",
|
||||
}
|
||||
|
||||
# Get the tree
|
||||
tree_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/trees/{tree_sha}"
|
||||
params = {}
|
||||
if recursive:
|
||||
params["recursive"] = "true"
|
||||
|
||||
tree_result = await self._request("GET", tree_endpoint, params=params)
|
||||
|
||||
if not tree_result["success"]:
|
||||
return tree_result
|
||||
|
||||
tree_data = tree_result["data"]
|
||||
raw_entries = tree_data.get("tree", [])
|
||||
|
||||
# Format entries
|
||||
entries = []
|
||||
for entry in raw_entries:
|
||||
entry_type = entry.get("type", "")
|
||||
# Map git object types to readable types
|
||||
if entry_type == "blob":
|
||||
readable_type = "file"
|
||||
elif entry_type == "tree":
|
||||
readable_type = "dir"
|
||||
else:
|
||||
readable_type = entry_type
|
||||
|
||||
entries.append({
|
||||
"path": entry.get("path", ""),
|
||||
"type": readable_type,
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
})
|
||||
|
||||
# Sort: directories first, then files
|
||||
entries.sort(key=lambda e: (0 if e["type"] == "dir" else 1, e["path"].lower()))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"entries": entries,
|
||||
"branch": ref,
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"total": len(entries),
|
||||
"truncated": tree_data.get("truncated", False),
|
||||
}
|
||||
|
||||
|
||||
# Singleton client instance (lazy-loaded)
|
||||
_gitea_client: Optional[GiteaClient] = None
|
||||
|
||||
|
||||
def get_gitea_client() -> Optional[GiteaClient]:
|
||||
"""Get or create the singleton Gitea client.
|
||||
|
||||
Returns None if configuration is missing or invalid.
|
||||
"""
|
||||
global _gitea_client
|
||||
|
||||
if _gitea_client is not None:
|
||||
return _gitea_client
|
||||
|
||||
try:
|
||||
_gitea_client = GiteaClient()
|
||||
return _gitea_client
|
||||
except ValueError as e:
|
||||
logger.warning("[Gitea] Client not available: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[Gitea] Failed to initialize client: %s", e)
|
||||
return None
|
||||
684
llm_interface.py
684
llm_interface.py
@@ -1,42 +1,49 @@
|
||||
"""LLM Interface - Claude API, GLM, and other models.
|
||||
|
||||
Supports three modes for Claude:
|
||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Pro subscription
|
||||
Supports two modes for Claude:
|
||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||
- Set USE_AGENT_SDK=true (default)
|
||||
- Model: claude-sonnet-4-5-20250929 (default for all operations)
|
||||
- Optional: USE_OPUS_FOR_TOOLS=true (enables Opus for extremely intensive tasks only)
|
||||
- MCP Tools: File/system tools (read_file, write_file, edit_file, list_directory, run_command)
|
||||
- Traditional Tools: Google tools & weather (fall back to Direct API, requires ANTHROPIC_API_KEY)
|
||||
- Flat-rate subscription cost (no per-token charges for MCP tools)
|
||||
- All tools are MCP-based (no API key needed)
|
||||
- Tools registered via mcp_tools.py MCP server
|
||||
- Flat-rate subscription cost
|
||||
|
||||
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
||||
- Model: claude-sonnet-4-5-20250929 (cost-effective, never uses Opus)
|
||||
- Model: claude-sonnet-4-5-20250929
|
||||
- Requires ANTHROPIC_API_KEY in .env
|
||||
- Full tool support built-in (all tools via traditional API)
|
||||
|
||||
3. Legacy: Local Claude Code server - Set USE_CLAUDE_CODE_SERVER=true (deprecated)
|
||||
- For backward compatibility only
|
||||
- Uses traditional tool definitions from tools.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import requests
|
||||
from anthropic import Anthropic
|
||||
from anthropic.types import Message, ContentBlock, TextBlock, ToolUseBlock, Usage
|
||||
|
||||
from usage_tracker import UsageTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Ensure our debug messages are visible even if root logger is at WARNING.
|
||||
# Only add a handler if none exist (prevent duplicate output).
|
||||
if not logger.handlers:
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
))
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Try to import Agent SDK (optional dependency)
|
||||
try:
|
||||
from claude_agent_sdk import (
|
||||
query,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
SystemMessage,
|
||||
ClaudeAgentOptions,
|
||||
ResultMessage,
|
||||
)
|
||||
import anyio
|
||||
AGENT_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
AGENT_SDK_AVAILABLE = False
|
||||
@@ -47,29 +54,61 @@ _API_KEY_ENV_VARS = {
|
||||
"glm": "GLM_API_KEY",
|
||||
}
|
||||
|
||||
# Mode selection (priority order: USE_DIRECT_API > USE_CLAUDE_CODE_SERVER > default to Agent SDK)
|
||||
# Mode selection (priority: USE_DIRECT_API > default to Agent SDK)
|
||||
_USE_DIRECT_API = os.getenv("USE_DIRECT_API", "false").lower() == "true"
|
||||
_CLAUDE_CODE_SERVER_URL = os.getenv("CLAUDE_CODE_SERVER_URL", "http://localhost:8000")
|
||||
_USE_CLAUDE_CODE_SERVER = os.getenv("USE_CLAUDE_CODE_SERVER", "false").lower() == "true"
|
||||
# Agent SDK is the default if available and no other mode is explicitly enabled
|
||||
_USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
||||
|
||||
# Default models by provider
|
||||
_DEFAULT_MODELS = {
|
||||
"claude": "claude-sonnet-4-5-20250929", # For Direct API (pay-per-token) - Sonnet is cost-effective
|
||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929", # For Agent SDK (flat-rate) - Sonnet for normal operations
|
||||
"claude_agent_sdk_opus": "claude-opus-4-6", # For Agent SDK extremely intensive tasks only (flat-rate)
|
||||
"claude": "claude-sonnet-4-5-20250929",
|
||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
||||
"glm": "glm-4-plus",
|
||||
}
|
||||
|
||||
# When to use Opus (only on Agent SDK flat-rate mode)
|
||||
_USE_OPUS_FOR_TOOLS = os.getenv("USE_OPUS_FOR_TOOLS", "false").lower() == "true"
|
||||
|
||||
_GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
|
||||
# Track PIDs of claude.exe subprocesses we spawn (to avoid killing user's Claude Code session!)
|
||||
_TRACKED_CLAUDE_PIDS: Set[int] = set()
|
||||
_TRACKED_PIDS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _register_claude_subprocess(pid: int):
|
||||
"""Register a claude.exe subprocess PID for cleanup on exit."""
|
||||
with _TRACKED_PIDS_LOCK:
|
||||
_TRACKED_CLAUDE_PIDS.add(pid)
|
||||
logger.debug("[LLM] Registered claude.exe subprocess PID: %d", pid)
|
||||
|
||||
|
||||
def _cleanup_tracked_claude_processes():
|
||||
"""Kill only the claude.exe processes we spawned (not the user's Claude Code session!)"""
|
||||
with _TRACKED_PIDS_LOCK:
|
||||
if not _TRACKED_CLAUDE_PIDS:
|
||||
return
|
||||
|
||||
logger.info("[LLM] Cleaning up %d tracked claude.exe subprocess(es)", len(_TRACKED_CLAUDE_PIDS))
|
||||
for pid in _TRACKED_CLAUDE_PIDS:
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
subprocess.run(
|
||||
['taskkill', '/F', '/PID', str(pid), '/T'],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
else: # Linux/Mac
|
||||
subprocess.run(['kill', '-9', str(pid)], capture_output=True, timeout=2)
|
||||
logger.debug("[LLM] Killed claude.exe subprocess PID: %d", pid)
|
||||
except Exception as e:
|
||||
logger.debug("[LLM] Failed to kill PID %d: %s", pid, e)
|
||||
|
||||
_TRACKED_CLAUDE_PIDS.clear()
|
||||
|
||||
|
||||
# Register cleanup on exit (only kills our tracked subprocesses, not all claude.exe!)
|
||||
atexit.register(_cleanup_tracked_claude_processes)
|
||||
|
||||
|
||||
class LLMInterface:
|
||||
"""Simple LLM interface supporting Claude and GLM."""
|
||||
"""LLM interface supporting Claude (Agent SDK or Direct API) and GLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -82,26 +121,27 @@ class LLMInterface:
|
||||
_API_KEY_ENV_VARS.get(provider, ""),
|
||||
)
|
||||
self.client: Optional[Anthropic] = None
|
||||
# Model will be set after determining mode
|
||||
|
||||
# Determine mode (priority: direct API > legacy server > agent SDK)
|
||||
# Reference to the main asyncio event loop, set by the runtime.
|
||||
# Used by Agent SDK mode to schedule async work from worker threads
|
||||
# via asyncio.run_coroutine_threadsafe().
|
||||
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Determine mode (priority: direct API > agent SDK)
|
||||
if provider == "claude":
|
||||
if _USE_DIRECT_API:
|
||||
self.mode = "direct_api"
|
||||
elif _USE_CLAUDE_CODE_SERVER:
|
||||
self.mode = "legacy_server"
|
||||
elif _USE_AGENT_SDK and AGENT_SDK_AVAILABLE:
|
||||
self.mode = "agent_sdk"
|
||||
else:
|
||||
# Fallback to direct API if Agent SDK not available
|
||||
self.mode = "direct_api"
|
||||
if _USE_AGENT_SDK and not AGENT_SDK_AVAILABLE:
|
||||
print("[LLM] Warning: Agent SDK not available, falling back to Direct API")
|
||||
print("[LLM] Install with: pip install claude-agent-sdk")
|
||||
else:
|
||||
self.mode = "direct_api" # Non-Claude providers use direct API
|
||||
self.mode = "direct_api"
|
||||
|
||||
# Usage tracking (disabled when using Agent SDK or legacy server)
|
||||
# Usage tracking (only for Direct API pay-per-token mode)
|
||||
self.tracker = UsageTracker() if (track_usage and self.mode == "direct_api") else None
|
||||
|
||||
# Set model based on mode
|
||||
@@ -109,28 +149,125 @@ class LLMInterface:
|
||||
if self.mode == "agent_sdk":
|
||||
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-5-20250929")
|
||||
else:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "claude-haiku-4-5-20251001")
|
||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
||||
else:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||
|
||||
# Initialize based on mode
|
||||
if provider == "claude":
|
||||
if self.mode == "agent_sdk":
|
||||
print(f"[LLM] Using Claude Agent SDK (flat-rate subscription) with model: {self.model}")
|
||||
# No initialization needed - query() is a standalone function
|
||||
print(f"[LLM] Using Agent SDK (Max subscription) with model: {self.model}")
|
||||
elif self.mode == "direct_api":
|
||||
print(f"[LLM] Using Direct API (pay-per-token) with model: {self.model}")
|
||||
self.client = Anthropic(api_key=self.api_key)
|
||||
elif self.mode == "legacy_server":
|
||||
print(f"[LLM] Using Claude Code server at {_CLAUDE_CODE_SERVER_URL} (Pro subscription) with model: {self.model}")
|
||||
# Verify server is running
|
||||
|
||||
def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Store a reference to the main asyncio event loop.
|
||||
|
||||
This allows Agent SDK async calls to be scheduled back onto the
|
||||
main event loop from worker threads (created by asyncio.to_thread).
|
||||
Must be called from the async context that owns the loop.
|
||||
"""
|
||||
self._event_loop = loop
|
||||
logger.info(
|
||||
"[LLM] Event loop stored: %s (running=%s)",
|
||||
type(loop).__name__,
|
||||
loop.is_running(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clean_claude_env() -> dict:
|
||||
"""Remove Claude Code session markers from the environment.
|
||||
|
||||
The Agent SDK's SubprocessCLITransport copies os.environ into the
|
||||
child process. If the bot is launched from within a Claude Code
|
||||
session (or any environment that sets CLAUDECODE), the child
|
||||
``claude`` CLI detects the nesting and refuses to start with:
|
||||
|
||||
"Claude Code cannot be launched inside another Claude Code session."
|
||||
|
||||
This method temporarily removes the offending variables and returns
|
||||
them so the caller can restore them afterwards.
|
||||
"""
|
||||
saved = {}
|
||||
# Keys that signal an active Claude Code parent session.
|
||||
# CLAUDE_CODE_ENTRYPOINT and CLAUDE_AGENT_SDK_VERSION are set by
|
||||
# the SDK itself on the child process, so removing them from the
|
||||
# parent is safe -- the SDK will set them again.
|
||||
markers = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_AGENT_SDK_VERSION",
|
||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||
]
|
||||
for key in markers:
|
||||
if key in os.environ:
|
||||
saved[key] = os.environ.pop(key)
|
||||
if saved:
|
||||
logger.debug("[LLM] Cleaned Claude session env vars: %s", list(saved.keys()))
|
||||
return saved
|
||||
|
||||
@staticmethod
|
||||
def _restore_claude_env(saved: dict) -> None:
|
||||
"""Restore previously removed Claude session env vars."""
|
||||
os.environ.update(saved)
|
||||
|
||||
def _run_async_from_thread(self, coro) -> Any:
|
||||
"""Run an async coroutine from a synchronous worker thread.
|
||||
|
||||
Uses asyncio.run_coroutine_threadsafe() to schedule the coroutine
|
||||
on the main event loop (if available), which is the correct way to
|
||||
bridge sync -> async when called from an asyncio.to_thread() worker
|
||||
or from any background thread (e.g., the scheduler).
|
||||
|
||||
Falls back to asyncio.run() if no event loop reference is available
|
||||
(e.g., direct script usage without the adapter runtime).
|
||||
|
||||
Args:
|
||||
coro: An already-created coroutine object (not a coroutine function).
|
||||
"""
|
||||
current_thread = threading.current_thread().name
|
||||
has_loop = self._event_loop is not None
|
||||
loop_running = has_loop and self._event_loop.is_running()
|
||||
|
||||
if has_loop and loop_running:
|
||||
logger.info(
|
||||
"[LLM] _run_async_from_thread: using run_coroutine_threadsafe "
|
||||
"(thread=%s, loop=%s)",
|
||||
current_thread,
|
||||
type(self._event_loop).__name__,
|
||||
)
|
||||
# Schedule on the main event loop and block this thread until done.
|
||||
# This works because:
|
||||
# 1. asyncio.to_thread() runs us in a thread pool while the main
|
||||
# loop continues processing other tasks.
|
||||
# 2. Scheduler threads are plain daemon threads, also not blocking
|
||||
# the main loop.
|
||||
# The coroutine executes on the main loop without deadlocking
|
||||
# because the main loop is free to run while we block here.
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._event_loop)
|
||||
try:
|
||||
response = requests.get(f"{_CLAUDE_CODE_SERVER_URL}/", timeout=2)
|
||||
response.raise_for_status()
|
||||
print(f"[LLM] Claude Code server is running: {response.json()}")
|
||||
except Exception as e:
|
||||
print(f"[LLM] Warning: Could not connect to Claude Code server: {e}")
|
||||
print(f"[LLM] Note: Claude Code server mode is deprecated. Using Agent SDK instead.")
|
||||
# Block with 10-minute timeout to prevent hangs
|
||||
# Complex tasks (repo analysis, multi-step operations) can take 5-8 minutes
|
||||
logger.info("[LLM] Waiting for Agent SDK response (timeout: 600s)...")
|
||||
result = future.result(timeout=600)
|
||||
logger.info("[LLM] Agent SDK response received successfully")
|
||||
return result
|
||||
except TimeoutError:
|
||||
logger.error("[LLM] ⚠️ Agent SDK call TIMED OUT after 600 seconds!")
|
||||
future.cancel() # Cancel the coroutine
|
||||
raise TimeoutError("Agent SDK call exceeded 10 minute timeout - task may be too complex")
|
||||
else:
|
||||
logger.info(
|
||||
"[LLM] _run_async_from_thread: using asyncio.run() fallback "
|
||||
"(thread=%s, has_loop=%s, loop_running=%s)",
|
||||
current_thread,
|
||||
has_loop,
|
||||
loop_running,
|
||||
)
|
||||
# Fallback: no main loop available (standalone / test usage).
|
||||
# Create a new event loop in this thread via asyncio.run().
|
||||
return asyncio.run(coro)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
@@ -140,44 +277,24 @@ class LLMInterface:
|
||||
) -> str:
|
||||
"""Send chat request and get response.
|
||||
|
||||
In Agent SDK mode, this uses query() which handles MCP tools automatically.
|
||||
In Direct API mode, this is a simple messages.create() call without tools.
|
||||
|
||||
Raises:
|
||||
Exception: If the API call fails or returns an unexpected response.
|
||||
"""
|
||||
if self.provider == "claude":
|
||||
# Agent SDK mode (Pro subscription)
|
||||
if self.mode == "agent_sdk":
|
||||
try:
|
||||
# Use anyio.run to create event loop for async SDK
|
||||
response = anyio.run(
|
||||
self._agent_sdk_chat,
|
||||
messages,
|
||||
system,
|
||||
max_tokens
|
||||
logger.info("[LLM] chat: dispatching via Agent SDK")
|
||||
response = self._run_async_from_thread(
|
||||
self._agent_sdk_chat(messages, system, max_tokens)
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("[LLM] Agent SDK error in chat(): %s", e, exc_info=True)
|
||||
raise Exception(f"Agent SDK error: {e}")
|
||||
|
||||
# Legacy Claude Code server (Pro subscription)
|
||||
elif self.mode == "legacy_server":
|
||||
try:
|
||||
payload = {
|
||||
"messages": [{"role": m["role"], "content": m["content"]} for m in messages],
|
||||
"system": system,
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
response = requests.post(
|
||||
f"{_CLAUDE_CODE_SERVER_URL}/v1/chat",
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("content", "")
|
||||
except Exception as e:
|
||||
raise Exception(f"Claude Code server error: {e}")
|
||||
|
||||
# Direct API (pay-per-token)
|
||||
elif self.mode == "direct_api":
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
@@ -186,7 +303,6 @@ class LLMInterface:
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Track usage
|
||||
if self.tracker and hasattr(response, "usage"):
|
||||
self.tracker.track(
|
||||
model=self.model,
|
||||
@@ -222,177 +338,263 @@ class LLMInterface:
|
||||
|
||||
raise ValueError(f"Unsupported provider: {self.provider}")
|
||||
|
||||
async def _agent_sdk_chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
system: Optional[str],
|
||||
max_tokens: int
|
||||
) -> str:
|
||||
"""Internal async method for Agent SDK chat (called via anyio bridge)."""
|
||||
# Convert messages to SDK format
|
||||
sdk_messages = []
|
||||
for msg in messages:
|
||||
if msg["role"] == "user":
|
||||
sdk_messages.append(UserMessage(content=msg["content"]))
|
||||
elif msg["role"] == "assistant":
|
||||
sdk_messages.append(AssistantMessage(content=msg["content"]))
|
||||
def _build_agent_sdk_options(self) -> Optional['ClaudeAgentOptions']:
|
||||
"""Build Agent SDK options with MCP servers and allowed tools.
|
||||
|
||||
# Add system message if provided
|
||||
if system:
|
||||
sdk_messages.insert(0, SystemMessage(content=system))
|
||||
|
||||
# Configure MCP server for file/system tools
|
||||
Returns configured ClaudeAgentOptions, or None if mcp_tools is unavailable.
|
||||
"""
|
||||
try:
|
||||
from mcp_tools import file_system_server
|
||||
|
||||
options = ClaudeAgentOptions(
|
||||
mcp_servers={"file_system": file_system_server},
|
||||
# Allow all MCP tools (file/system + web + zettelkasten)
|
||||
allowed_tools=[
|
||||
mcp_servers = {"file_system": file_system_server}
|
||||
|
||||
# All tools registered in the MCP server
|
||||
allowed_tools = [
|
||||
# File and system tools
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"list_directory",
|
||||
"run_command",
|
||||
# Web tool
|
||||
"web_fetch",
|
||||
# Zettelkasten tools
|
||||
"fleeting_note",
|
||||
"daily_note",
|
||||
"literature_note",
|
||||
"permanent_note",
|
||||
"search_vault",
|
||||
"search_by_tags",
|
||||
],
|
||||
# Google tools (Gmail, Calendar, Contacts)
|
||||
"get_weather",
|
||||
"send_email",
|
||||
"read_emails",
|
||||
"get_email",
|
||||
"read_calendar",
|
||||
"create_calendar_event",
|
||||
"search_calendar",
|
||||
"create_contact",
|
||||
"list_contacts",
|
||||
"get_contact",
|
||||
# Gitea tools (private repo access)
|
||||
"gitea_read_file",
|
||||
"gitea_list_files",
|
||||
"gitea_search_code",
|
||||
"gitea_get_tree",
|
||||
]
|
||||
|
||||
# Conditionally add Obsidian MCP server
|
||||
try:
|
||||
from obsidian_mcp import (
|
||||
is_obsidian_enabled,
|
||||
check_obsidian_health,
|
||||
get_obsidian_server_config,
|
||||
OBSIDIAN_TOOLS,
|
||||
)
|
||||
|
||||
if is_obsidian_enabled() and check_obsidian_health():
|
||||
obsidian_config = get_obsidian_server_config()
|
||||
mcp_servers["obsidian"] = obsidian_config
|
||||
allowed_tools.extend(OBSIDIAN_TOOLS)
|
||||
print("[LLM] Obsidian MCP server registered (8 tools)")
|
||||
elif is_obsidian_enabled():
|
||||
print("[LLM] Obsidian MCP enabled but health check failed")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[LLM] Obsidian MCP unavailable: {e}")
|
||||
|
||||
def _stderr_callback(line: str) -> None:
|
||||
"""Log Claude CLI stderr for debugging transport failures."""
|
||||
logger.debug("[CLI stderr] %s", line)
|
||||
|
||||
return ClaudeAgentOptions(
|
||||
mcp_servers=mcp_servers,
|
||||
allowed_tools=allowed_tools,
|
||||
permission_mode="bypassPermissions",
|
||||
max_turns=30, # Prevent infinite tool loops (matches MAX_TOOL_ITERATIONS)
|
||||
stderr=_stderr_callback,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback if mcp_tools not available
|
||||
options = None
|
||||
print("[LLM] Warning: mcp_tools not available, no MCP tools will be registered")
|
||||
return None
|
||||
|
||||
# Call the new query() API
|
||||
# Note: Agent SDK handles max_tokens internally, don't pass it explicitly
|
||||
response = await query(
|
||||
messages=sdk_messages,
|
||||
options=options,
|
||||
# model parameter is handled by the SDK based on settings
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
if hasattr(response, "content"):
|
||||
# Handle list of content blocks
|
||||
if isinstance(response.content, list):
|
||||
text_parts = []
|
||||
for block in response.content:
|
||||
if hasattr(block, "text"):
|
||||
text_parts.append(block.text)
|
||||
return "".join(text_parts)
|
||||
# Handle single text content
|
||||
elif isinstance(response.content, str):
|
||||
return response.content
|
||||
|
||||
return str(response)
|
||||
|
||||
async def _agent_sdk_chat_with_tools(
|
||||
async def _agent_sdk_chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict[str, Any]],
|
||||
system: Optional[str],
|
||||
max_tokens: int
|
||||
) -> Message:
|
||||
"""Internal async method for Agent SDK chat with tools (called via anyio bridge).
|
||||
) -> str:
|
||||
"""Agent SDK chat via custom transport flow.
|
||||
|
||||
NOTE: The new Claude Agent SDK (v0.1.36+) uses MCP servers for tools.
|
||||
For backward compatibility with the existing tool system, we fall back
|
||||
to the Direct API for tool calls. This means tool calls will consume API tokens
|
||||
even when Agent SDK mode is enabled.
|
||||
Uses the SDK's transport and query layers directly instead of the
|
||||
high-level ``query()`` helper. This works around a bug in
|
||||
``claude_agent_sdk._internal.client.process_query`` where
|
||||
``end_input()`` is called immediately after sending the user message
|
||||
for string prompts. That premature stdin close kills the
|
||||
bidirectional control channel that SDK MCP servers need to handle
|
||||
``tools/list`` and ``tools/call`` requests from the CLI subprocess,
|
||||
resulting in ``CLIConnectionError: ProcessTransport is not ready for
|
||||
writing``.
|
||||
|
||||
Uses Sonnet by default. Opus can be enabled via USE_OPUS_FOR_TOOLS=true for
|
||||
extremely intensive tasks (only recommended for Agent SDK flat-rate mode).
|
||||
Our fix: defer ``end_input()`` until after the first ``ResultMessage``
|
||||
is received, matching the logic already present in
|
||||
``Query.stream_input()`` for async-iterable prompts.
|
||||
"""
|
||||
# Fallback to Direct API for tool calls (SDK tools use MCP servers)
|
||||
from anthropic import Anthropic
|
||||
import json as _json
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
"ANTHROPIC_API_KEY required for tool calls in Agent SDK mode. "
|
||||
"Set the API key in .env or migrate tools to MCP servers."
|
||||
# Lazy imports from SDK internals.
|
||||
from claude_agent_sdk._internal.transport.subprocess_cli import (
|
||||
SubprocessCLITransport,
|
||||
)
|
||||
from claude_agent_sdk._internal.query import Query
|
||||
from claude_agent_sdk._internal.message_parser import parse_message
|
||||
|
||||
# Build the prompt from the system prompt and conversation history.
|
||||
prompt = self._build_sdk_prompt(messages, system)
|
||||
options = self._build_agent_sdk_options()
|
||||
|
||||
# Clean Claude session env vars so the child CLI process doesn't
|
||||
# detect a "nested session" and refuse to start.
|
||||
saved_env = self._clean_claude_env()
|
||||
|
||||
try:
|
||||
# --- 1. Create and connect the subprocess transport. ---
|
||||
transport = SubprocessCLITransport(prompt=prompt, options=options)
|
||||
await transport.connect()
|
||||
|
||||
# Track the subprocess PID for cleanup on exit
|
||||
if hasattr(transport, '_process') and transport._process:
|
||||
_register_claude_subprocess(transport._process.pid)
|
||||
|
||||
# --- 2. Extract in-process SDK MCP server instances. ---
|
||||
sdk_mcp_servers: Dict = {}
|
||||
if options.mcp_servers and isinstance(options.mcp_servers, dict):
|
||||
for name, config in options.mcp_servers.items():
|
||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||
sdk_mcp_servers[name] = config["instance"]
|
||||
|
||||
# --- 3. Create the Query object (control-protocol handler). ---
|
||||
query_obj = Query(
|
||||
transport=transport,
|
||||
is_streaming_mode=True,
|
||||
sdk_mcp_servers=sdk_mcp_servers,
|
||||
)
|
||||
|
||||
temp_client = Anthropic(api_key=self.api_key)
|
||||
try:
|
||||
# Start the background reader task.
|
||||
await query_obj.start()
|
||||
|
||||
# Use Opus only if explicitly enabled (for intensive tasks on flat-rate)
|
||||
# Otherwise default to Sonnet (cost-effective for normal tool operations)
|
||||
if _USE_OPUS_FOR_TOOLS and self.mode == "agent_sdk":
|
||||
model = _DEFAULT_MODELS.get("claude_agent_sdk_opus", "claude-opus-4-6")
|
||||
else:
|
||||
model = self.model # Use Sonnet (default)
|
||||
# Perform the initialize handshake with the CLI.
|
||||
await query_obj.initialize()
|
||||
|
||||
response = temp_client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
system=system or "",
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
# Send the user message over stdin.
|
||||
user_msg = {
|
||||
"type": "user",
|
||||
"session_id": "",
|
||||
"message": {"role": "user", "content": prompt},
|
||||
"parent_tool_use_id": None,
|
||||
}
|
||||
await transport.write(_json.dumps(user_msg) + "\n")
|
||||
|
||||
# **KEY FIX**: Do NOT call end_input() yet. The CLI will
|
||||
# send MCP control requests (tools/list, tools/call) over
|
||||
# the bidirectional channel. Closing stdin now would
|
||||
# prevent us from writing responses back. We wait for the
|
||||
# first ResultMessage instead.
|
||||
|
||||
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||
result_text = ""
|
||||
message_count = 0
|
||||
async for data in query_obj.receive_messages():
|
||||
message = parse_message(data)
|
||||
message_count += 1
|
||||
|
||||
# Log all message types for debugging hangs
|
||||
message_type = type(message).__name__
|
||||
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
||||
|
||||
if isinstance(message, ResultMessage):
|
||||
result_text = message.result or ""
|
||||
logger.info(
|
||||
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||
message_count,
|
||||
getattr(message, "total_cost_usd", 0),
|
||||
getattr(message, "num_turns", "?"),
|
||||
)
|
||||
break
|
||||
|
||||
return response
|
||||
# Log non-result messages to detect loops
|
||||
if message_count % 10 == 0:
|
||||
logger.warning(f"[LLM] Still waiting for ResultMessage after {message_count} messages...")
|
||||
|
||||
def _convert_sdk_response_to_message(self, sdk_response: Dict[str, Any]) -> Message:
|
||||
"""Convert Agent SDK response to anthropic.types.Message format.
|
||||
# Now that we have the result, close stdin gracefully.
|
||||
try:
|
||||
await transport.end_input()
|
||||
except Exception:
|
||||
pass # Process may have already exited; that's fine.
|
||||
|
||||
This ensures compatibility with agent.py's existing tool loop.
|
||||
return result_text
|
||||
|
||||
finally:
|
||||
# Always clean up the query/transport.
|
||||
try:
|
||||
await query_obj.close()
|
||||
except Exception:
|
||||
# Suppress errors during cleanup (e.g. if process
|
||||
# already exited and there are pending control
|
||||
# request tasks that can't write back).
|
||||
pass
|
||||
finally:
|
||||
# Always restore env vars, even on error.
|
||||
self._restore_claude_env(saved_env)
|
||||
|
||||
def _build_sdk_prompt(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
system: Optional[str],
|
||||
) -> str:
|
||||
"""Build a prompt string for the Agent SDK query() from conversation history.
|
||||
|
||||
The SDK expects a single prompt string. We combine the system prompt
|
||||
and conversation history into a coherent prompt.
|
||||
"""
|
||||
# Extract content blocks
|
||||
content_blocks = []
|
||||
raw_content = sdk_response.get("content", [])
|
||||
parts = []
|
||||
|
||||
if isinstance(raw_content, str):
|
||||
# Simple text response
|
||||
content_blocks = [TextBlock(type="text", text=raw_content)]
|
||||
elif isinstance(raw_content, list):
|
||||
# List of content blocks
|
||||
for block in raw_content:
|
||||
if system:
|
||||
parts.append(f"<system>\n{system}\n</system>\n")
|
||||
|
||||
# Include recent conversation history for context
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
role = msg["role"]
|
||||
|
||||
if isinstance(content, str):
|
||||
if role == "user":
|
||||
parts.append(f"User: {content}")
|
||||
elif role == "assistant":
|
||||
parts.append(f"Assistant: {content}")
|
||||
elif isinstance(content, list):
|
||||
# Structured content (tool_use/tool_result blocks from Direct API history)
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
content_blocks.append(TextBlock(
|
||||
type="text",
|
||||
text=block.get("text", "")
|
||||
))
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block.get("type") == "tool_result":
|
||||
text_parts.append(f"[Tool result]: {block.get('content', '')}")
|
||||
elif block.get("type") == "tool_use":
|
||||
content_blocks.append(ToolUseBlock(
|
||||
type="tool_use",
|
||||
id=block.get("id", ""),
|
||||
name=block.get("name", ""),
|
||||
input=block.get("input", {})
|
||||
))
|
||||
text_parts.append(f"[Used tool: {block.get('name', 'unknown')}]")
|
||||
elif hasattr(block, "type"):
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
if text_parts:
|
||||
if role == "user":
|
||||
parts.append(f"User: {' '.join(text_parts)}")
|
||||
elif role == "assistant":
|
||||
parts.append(f"Assistant: {' '.join(text_parts)}")
|
||||
|
||||
# Extract usage information
|
||||
usage_data = sdk_response.get("usage", {})
|
||||
usage = Usage(
|
||||
input_tokens=usage_data.get("input_tokens", 0),
|
||||
output_tokens=usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# Create Message object
|
||||
# Note: We create a minimal Message-compatible object
|
||||
# The Message class from anthropic.types is read-only, so we create a mock
|
||||
# Capture self.model before defining inner class
|
||||
model_name = sdk_response.get("model", self.model)
|
||||
|
||||
class MessageLike:
|
||||
def __init__(self, content, stop_reason, usage, model):
|
||||
self.content = content
|
||||
self.stop_reason = stop_reason
|
||||
self.usage = usage
|
||||
self.id = sdk_response.get("id", "sdk_message")
|
||||
self.model = model
|
||||
self.role = "assistant"
|
||||
self.type = "message"
|
||||
|
||||
return MessageLike(
|
||||
content=content_blocks,
|
||||
stop_reason=sdk_response.get("stop_reason", "end_turn"),
|
||||
usage=usage,
|
||||
model=model_name
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
@@ -401,70 +603,43 @@ class LLMInterface:
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = 16384,
|
||||
use_cache: bool = False,
|
||||
) -> Message:
|
||||
"""Send chat request with tool support. Returns full Message object.
|
||||
) -> Any:
|
||||
"""Send chat request with tool support.
|
||||
|
||||
In Agent SDK mode: Uses query() with MCP tools. The SDK handles tool
|
||||
execution automatically. Returns a string (final response after all
|
||||
tool calls are resolved).
|
||||
|
||||
In Direct API mode: Returns an anthropic Message object with potential
|
||||
tool_use blocks that agent.py processes in a manual loop.
|
||||
|
||||
Args:
|
||||
use_cache: Enable prompt caching for Sonnet models (saves 90% on repeated context)
|
||||
tools: Tool definitions (used by Direct API; ignored in Agent SDK mode
|
||||
since tools are registered via MCP servers).
|
||||
use_cache: Enable prompt caching for Sonnet (Direct API only).
|
||||
"""
|
||||
if self.provider != "claude":
|
||||
raise ValueError("Tool use only supported for Claude provider")
|
||||
|
||||
# Agent SDK mode (Pro subscription)
|
||||
if self.mode == "agent_sdk":
|
||||
# Agent SDK handles tool calls automatically via MCP servers.
|
||||
# We use the same query() path as chat(), since MCP tools are
|
||||
# already registered. The SDK will invoke tools, collect results,
|
||||
# and return the final text response.
|
||||
try:
|
||||
# Use anyio.run to create event loop for async SDK
|
||||
response = anyio.run(
|
||||
self._agent_sdk_chat_with_tools,
|
||||
messages,
|
||||
tools,
|
||||
system,
|
||||
max_tokens
|
||||
logger.info("[LLM] chat_with_tools: dispatching via Agent SDK")
|
||||
response = self._run_async_from_thread(
|
||||
self._agent_sdk_chat(messages, system, max_tokens)
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("[LLM] Agent SDK error: %s", e, exc_info=True)
|
||||
raise Exception(f"Agent SDK error: {e}")
|
||||
|
||||
# Legacy Claude Code server (Pro subscription)
|
||||
elif self.mode == "legacy_server":
|
||||
try:
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"system": system,
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
response = requests.post(
|
||||
f"{_CLAUDE_CODE_SERVER_URL}/v1/chat/tools",
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Convert response to Message-like object
|
||||
data = response.json()
|
||||
|
||||
# Create a mock Message object with the response
|
||||
class MockMessage:
|
||||
def __init__(self, data):
|
||||
self.content = data.get("content", [])
|
||||
self.stop_reason = data.get("stop_reason", "end_turn")
|
||||
self.usage = type('obj', (object,), {
|
||||
'input_tokens': data.get("usage", {}).get("input_tokens", 0),
|
||||
'output_tokens': data.get("usage", {}).get("output_tokens", 0)
|
||||
})
|
||||
|
||||
return MockMessage(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"Claude Code server error: {e}")
|
||||
|
||||
# Direct API (pay-per-token)
|
||||
elif self.mode == "direct_api":
|
||||
# Enable caching only for Sonnet models (not worth it for Haiku)
|
||||
enable_caching = use_cache and "sonnet" in self.model.lower()
|
||||
|
||||
# Structure system prompt for optimal caching
|
||||
if enable_caching and system:
|
||||
# Convert string to list format with cache control
|
||||
system_blocks = [
|
||||
{
|
||||
"type": "text",
|
||||
@@ -483,7 +658,6 @@ class LLMInterface:
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
# Track usage
|
||||
if self.tracker and hasattr(response, "usage"):
|
||||
self.tracker.track(
|
||||
model=self.model,
|
||||
|
||||
771
mcp_tools.py
771
mcp_tools.py
@@ -1033,22 +1033,789 @@ async def search_vault_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Create the MCP server with all tools (file/system + web + zettelkasten)
|
||||
# ============================================
|
||||
# Google and Weather Tools (MCP Migration)
|
||||
# ============================================
|
||||
# Lazy-loaded Google clients
|
||||
_gmail_client: Optional[Any] = None
|
||||
_calendar_client: Optional[Any] = None
|
||||
_people_client: Optional[Any] = None
|
||||
|
||||
|
||||
def _initialize_google_clients():
|
||||
"""Lazy-load Google API clients when needed."""
|
||||
global _gmail_client, _calendar_client, _people_client
|
||||
|
||||
if _gmail_client is not None:
|
||||
return _gmail_client, _calendar_client, _people_client
|
||||
|
||||
try:
|
||||
from google_tools.gmail_client import GmailClient
|
||||
from google_tools.calendar_client import CalendarClient
|
||||
from google_tools.people_client import PeopleClient
|
||||
from google_tools.oauth_manager import GoogleOAuthManager
|
||||
|
||||
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"[MCP Google] Failed to initialize: {e}")
|
||||
return None, None, None
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_weather",
|
||||
description="Get current weather for a location using OpenWeatherMap API. Returns temperature, conditions, and description.",
|
||||
input_schema={"location": str},
|
||||
)
|
||||
async def get_weather(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get current weather for a location using OpenWeatherMap API."""
|
||||
location = args.get("location", "Phoenix, US")
|
||||
import os
|
||||
import requests
|
||||
|
||||
api_key = os.getenv("OPENWEATHERMAP_API_KEY")
|
||||
if not api_key:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Error: OPENWEATHERMAP_API_KEY not found in environment variables"
|
||||
}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
try:
|
||||
base_url = "http://api.openweathermap.org/data/2.5/weather"
|
||||
params = {
|
||||
"q": location,
|
||||
"appid": api_key,
|
||||
"units": "imperial"
|
||||
}
|
||||
|
||||
response = requests.get(base_url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
temp = data["main"]["temp"]
|
||||
feels_like = data["main"]["feels_like"]
|
||||
humidity = data["main"]["humidity"]
|
||||
conditions = data["weather"][0]["main"]
|
||||
description = data["weather"][0]["description"]
|
||||
city_name = data["name"]
|
||||
|
||||
summary = (
|
||||
f"Weather in {city_name}:\n"
|
||||
f"Temperature: {temp}°F (feels like {feels_like}°F)\n"
|
||||
f"Conditions: {conditions} - {description}\n"
|
||||
f"Humidity: {humidity}%"
|
||||
)
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": summary}]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": f"Error getting weather: {str(e)}"
|
||||
}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="send_email",
|
||||
description="Send an email via Gmail API. Requires prior OAuth setup (--setup-google).",
|
||||
input_schema={"to": str, "subject": str, "body": str, "cc": str, "reply_to_message_id": str},
|
||||
)
|
||||
async def send_email(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Send an email via Gmail API."""
|
||||
to = args["to"]
|
||||
subject = args["subject"]
|
||||
body = args["body"]
|
||||
cc = args.get("cc")
|
||||
reply_to_message_id = args.get("reply_to_message_id")
|
||||
|
||||
gmail_client, _, _ = _initialize_google_clients()
|
||||
|
||||
if not gmail_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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")
|
||||
text = f"Email sent successfully to {to}\nMessage ID: {msg_id}\nSubject: {subject}"
|
||||
else:
|
||||
text = f"Error sending email: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_emails",
|
||||
description="Search and read emails from Gmail using Gmail query syntax (e.g., 'from:user@example.com after:2026/02/10').",
|
||||
input_schema={"query": str, "max_results": int, "include_body": bool},
|
||||
)
|
||||
async def read_emails(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Search and read emails from Gmail."""
|
||||
query = args.get("query", "")
|
||||
max_results = args.get("max_results", 10)
|
||||
include_body = args.get("include_body", False)
|
||||
|
||||
gmail_client, _, _ = _initialize_google_clients()
|
||||
|
||||
if not gmail_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)"
|
||||
else:
|
||||
summary = f"Error reading emails: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": summary}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_email",
|
||||
description="Get full content of a specific email by its Gmail message ID.",
|
||||
input_schema={"message_id": str, "format_type": str},
|
||||
)
|
||||
async def get_email(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get full content of a specific email."""
|
||||
message_id = args["message_id"]
|
||||
format_type = args.get("format_type", "text")
|
||||
|
||||
gmail_client, _, _ = _initialize_google_clients()
|
||||
|
||||
if not gmail_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
result = gmail_client.get_email(message_id=message_id, format_type=format_type)
|
||||
|
||||
if result["success"]:
|
||||
email_data = result.get("email", {})
|
||||
text = (
|
||||
f"From: {email_data.get('from', 'Unknown')}\n"
|
||||
f"To: {email_data.get('to', 'Unknown')}\n"
|
||||
f"Subject: {email_data.get('subject', 'No subject')}\n"
|
||||
f"Date: {email_data.get('date', 'Unknown')}\n\n"
|
||||
f"{email_data.get('body', 'No content')}"
|
||||
)
|
||||
if len(text) > _MAX_TOOL_OUTPUT:
|
||||
text = text[:_MAX_TOOL_OUTPUT] + "\n... (content truncated)"
|
||||
else:
|
||||
text = f"Error getting email: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="read_calendar",
|
||||
description="Read upcoming events from Google Calendar. Shows events from today onwards.",
|
||||
input_schema={"days_ahead": int, "calendar_id": str, "max_results": int},
|
||||
)
|
||||
async def read_calendar(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Read upcoming calendar events."""
|
||||
days_ahead = args.get("days_ahead", 7)
|
||||
calendar_id = args.get("calendar_id", "primary")
|
||||
max_results = args.get("max_results", 20)
|
||||
|
||||
_, calendar_client, _ = _initialize_google_clients()
|
||||
|
||||
if not calendar_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)"
|
||||
text = f"Upcoming events (next {days_ahead} days):\n\n{summary}"
|
||||
else:
|
||||
text = f"Error reading calendar: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_calendar_event",
|
||||
description="Create a new event in Google Calendar. Use ISO 8601 format for times.",
|
||||
input_schema={
|
||||
"summary": str, "start_time": str, "end_time": str,
|
||||
"description": str, "location": str, "calendar_id": str,
|
||||
},
|
||||
)
|
||||
async def create_calendar_event(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new calendar event."""
|
||||
summary = args["summary"]
|
||||
start_time = args["start_time"]
|
||||
end_time = args["end_time"]
|
||||
description = args.get("description", "")
|
||||
location = args.get("location", "")
|
||||
calendar_id = args.get("calendar_id", "primary")
|
||||
|
||||
_, calendar_client, _ = _initialize_google_clients()
|
||||
|
||||
if not calendar_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)
|
||||
text = (
|
||||
f"Calendar event created successfully!\n"
|
||||
f"Title: {summary}\nStart: {start}\n"
|
||||
f"Event ID: {event_id}\nLink: {html_link}"
|
||||
)
|
||||
else:
|
||||
text = f"Error creating calendar event: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_calendar",
|
||||
description="Search calendar events by text query. Searches event titles and descriptions.",
|
||||
input_schema={"query": str, "calendar_id": str},
|
||||
)
|
||||
async def search_calendar(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Search calendar events by text query."""
|
||||
query = args["query"]
|
||||
calendar_id = args.get("calendar_id", "primary")
|
||||
|
||||
_, calendar_client, _ = _initialize_google_clients()
|
||||
|
||||
if not calendar_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)"
|
||||
text = f"Calendar search results for '{query}':\n\n{summary}"
|
||||
else:
|
||||
text = f"Error searching calendar: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Contacts Tools (MCP)
|
||||
# ============================================
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_contact",
|
||||
description="Create a new Google contact. Requires prior OAuth setup (--setup-google).",
|
||||
input_schema={"given_name": str, "family_name": str, "email": str, "phone": str, "notes": str},
|
||||
)
|
||||
async def create_contact(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new Google contact."""
|
||||
given_name = args["given_name"]
|
||||
family_name = args.get("family_name", "")
|
||||
email = args.get("email", "")
|
||||
phone = args.get("phone")
|
||||
notes = args.get("notes")
|
||||
|
||||
_, _, people_client = _initialize_google_clients()
|
||||
|
||||
if not people_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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", "")
|
||||
text = f"Contact created: {name}\nResource: {resource}"
|
||||
else:
|
||||
text = f"Error creating contact: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_contacts",
|
||||
description="List or search Google contacts. Without a query, lists all contacts sorted by last name.",
|
||||
input_schema={"max_results": int, "query": str},
|
||||
)
|
||||
async def list_contacts(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List or search Google contacts."""
|
||||
max_results = args.get("max_results", 100)
|
||||
query = args.get("query")
|
||||
|
||||
_, _, people_client = _initialize_google_clients()
|
||||
|
||||
if not people_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)"
|
||||
text = f"Contacts ({result.get('count', 0)} found):\n\n{summary}"
|
||||
else:
|
||||
text = f"Error listing contacts: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_contact",
|
||||
description="Get full details of a specific Google contact by resource name.",
|
||||
input_schema={"resource_name": str},
|
||||
)
|
||||
async def get_contact(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get full details of a specific Google contact."""
|
||||
resource_name = args["resource_name"]
|
||||
|
||||
_, _, people_client = _initialize_google_clients()
|
||||
|
||||
if not people_client:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: Google not authorized. Run: python bot_runner.py --setup-google"}],
|
||||
"isError": True
|
||||
}
|
||||
|
||||
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)}")
|
||||
text = "\n".join(output)
|
||||
else:
|
||||
text = f"Error getting contact: {result.get('error', 'Unknown error')}"
|
||||
|
||||
return {"content": [{"type": "text", "text": text}], "isError": not result["success"]}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Gitea Tools (MCP) - Private repo access
|
||||
# ============================================
|
||||
|
||||
# Lazy-loaded Gitea client
|
||||
_gitea_client: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_gitea_client():
|
||||
"""Lazy-load Gitea client when first needed."""
|
||||
global _gitea_client
|
||||
|
||||
if _gitea_client is not None:
|
||||
return _gitea_client
|
||||
|
||||
try:
|
||||
from gitea_tools.client import get_gitea_client
|
||||
_gitea_client = get_gitea_client()
|
||||
return _gitea_client
|
||||
except Exception as e:
|
||||
print(f"[MCP Gitea] Failed to initialize: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="gitea_read_file",
|
||||
description="Read a file from a Gitea repository. Use this to access files from Jordan's homelab repo or any configured Gitea repo. Returns the file content as text.",
|
||||
input_schema={
|
||||
"file_path": str,
|
||||
"repo": str,
|
||||
"branch": str,
|
||||
},
|
||||
)
|
||||
async def gitea_read_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Read a file from a Gitea repository.
|
||||
|
||||
Zero-cost MCP tool for accessing private Gitea repos.
|
||||
"""
|
||||
file_path = args.get("file_path", "")
|
||||
repo = args.get("repo")
|
||||
branch = args.get("branch")
|
||||
|
||||
if not file_path:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: file_path is required"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
client = _get_gitea_client()
|
||||
if not client:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Error: Gitea not configured. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml "
|
||||
"and add your Personal Access Token."
|
||||
),
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
# Parse owner/repo if provided
|
||||
owner = None
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
|
||||
result = await client.get_file_content(
|
||||
file_path=file_path,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
metadata = result.get("metadata", {})
|
||||
path_info = metadata.get("path", file_path)
|
||||
size = metadata.get("size", 0)
|
||||
|
||||
header = f"File: {path_info} ({size:,} bytes)"
|
||||
if metadata.get("truncated"):
|
||||
header += " [TRUNCATED]"
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"{header}\n\n{content}"}],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error: {result['error']}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="gitea_list_files",
|
||||
description="List files and folders in a directory in a Gitea repository. Use this to explore the structure of Jordan's homelab repo or any configured Gitea repo.",
|
||||
input_schema={
|
||||
"path": str,
|
||||
"repo": str,
|
||||
"branch": str,
|
||||
},
|
||||
)
|
||||
async def gitea_list_files_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List files and directories in a Gitea repo path.
|
||||
|
||||
Zero-cost MCP tool for browsing private Gitea repos.
|
||||
"""
|
||||
path = args.get("path", "")
|
||||
repo = args.get("repo")
|
||||
branch = args.get("branch")
|
||||
|
||||
client = _get_gitea_client()
|
||||
if not client:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Error: Gitea not configured. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml "
|
||||
"and add your Personal Access Token."
|
||||
),
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
# Parse owner/repo if provided
|
||||
owner = None
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
|
||||
result = await client.list_files(
|
||||
path=path,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
files = result["files"]
|
||||
repo_name = result.get("repo", "")
|
||||
display_path = result.get("path", "/")
|
||||
count = result.get("count", 0)
|
||||
|
||||
# Format output
|
||||
lines = [f"Directory: {repo_name}/{display_path} ({count} items)\n"]
|
||||
for f in files:
|
||||
if f["type"] == "dir":
|
||||
lines.append(f" DIR {f['name']}/")
|
||||
else:
|
||||
size_str = f"({f['size']:,} bytes)" if f["size"] else ""
|
||||
lines.append(f" FILE {f['name']} {size_str}")
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": "\n".join(lines)}],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error: {result['error']}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="gitea_search_code",
|
||||
description="Search for files by name/path in a Gitea repository. Searches file and directory names. For content search, use gitea_read_file on specific files.",
|
||||
input_schema={
|
||||
"query": str,
|
||||
"repo": str,
|
||||
},
|
||||
)
|
||||
async def gitea_search_code_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Search for code/files in a Gitea repository.
|
||||
|
||||
Zero-cost MCP tool. Searches file/directory names in the repo tree.
|
||||
"""
|
||||
query = args.get("query", "")
|
||||
repo = args.get("repo")
|
||||
|
||||
if not query:
|
||||
return {
|
||||
"content": [{"type": "text", "text": "Error: query is required"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
client = _get_gitea_client()
|
||||
if not client:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Error: Gitea not configured. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml "
|
||||
"and add your Personal Access Token."
|
||||
),
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
# Parse owner/repo if provided
|
||||
owner = None
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
|
||||
result = await client.search_code(
|
||||
query=query,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
results = result.get("results", [])
|
||||
count = result.get("count", 0)
|
||||
repo_name = result.get("repo", "")
|
||||
|
||||
if not results:
|
||||
message = result.get("message", f"No results for '{query}'")
|
||||
return {
|
||||
"content": [{"type": "text", "text": message}],
|
||||
}
|
||||
|
||||
lines = [f"Search results for '{query}' in {repo_name} ({count} matches):\n"]
|
||||
for r in results:
|
||||
type_icon = "DIR " if r["type"] == "dir" else "FILE"
|
||||
size_str = f"({r['size']:,} bytes)" if r.get("size") else ""
|
||||
lines.append(f" {type_icon} {r['path']} {size_str}")
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": "\n".join(lines)}],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error: {result['error']}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="gitea_get_tree",
|
||||
description="Get the directory tree structure from a Gitea repository. Shows all files and folders. Use recursive=true for the full tree.",
|
||||
input_schema={
|
||||
"repo": str,
|
||||
"branch": str,
|
||||
"recursive": bool,
|
||||
},
|
||||
)
|
||||
async def gitea_get_tree_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get directory tree from a Gitea repository.
|
||||
|
||||
Zero-cost MCP tool for viewing repo structure.
|
||||
"""
|
||||
repo = args.get("repo")
|
||||
branch = args.get("branch")
|
||||
recursive = args.get("recursive", False)
|
||||
|
||||
client = _get_gitea_client()
|
||||
if not client:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Error: Gitea not configured. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml "
|
||||
"and add your Personal Access Token."
|
||||
),
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
# Parse owner/repo if provided
|
||||
owner = None
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
|
||||
result = await client.get_tree(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
entries = result.get("entries", [])
|
||||
repo_name = result.get("repo", "")
|
||||
branch_name = result.get("branch", "main")
|
||||
total = result.get("total", 0)
|
||||
truncated = result.get("truncated", False)
|
||||
|
||||
lines = [f"Tree: {repo_name} (branch: {branch_name}, {total} entries)"]
|
||||
if truncated:
|
||||
lines[0] += " [TRUNCATED - tree too large]"
|
||||
lines.append("")
|
||||
|
||||
for entry in entries:
|
||||
if entry["type"] == "dir":
|
||||
lines.append(f" {entry['path']}/")
|
||||
else:
|
||||
size_str = f"({entry['size']:,} bytes)" if entry.get("size") else ""
|
||||
lines.append(f" {entry['path']} {size_str}")
|
||||
|
||||
# Truncate output if too long
|
||||
text = "\n".join(lines)
|
||||
if len(text) > _MAX_TOOL_OUTPUT:
|
||||
text = text[:_MAX_TOOL_OUTPUT] + "\n\n... (tree truncated, use gitea_list_files for specific directories)"
|
||||
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error: {result['error']}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
|
||||
# Create the MCP server with all tools
|
||||
file_system_server = create_sdk_mcp_server(
|
||||
name="file_system",
|
||||
version="1.4.0",
|
||||
version="2.0.0",
|
||||
tools=[
|
||||
# File and system tools
|
||||
read_file_tool,
|
||||
write_file_tool,
|
||||
edit_file_tool,
|
||||
list_directory_tool,
|
||||
run_command_tool,
|
||||
# Web tool
|
||||
web_fetch_tool,
|
||||
# Zettelkasten tools
|
||||
fleeting_note_tool,
|
||||
daily_note_tool,
|
||||
literature_note_tool,
|
||||
permanent_note_tool,
|
||||
search_vault_tool,
|
||||
search_by_tags_tool,
|
||||
# Weather
|
||||
get_weather,
|
||||
# Gmail tools
|
||||
send_email,
|
||||
read_emails,
|
||||
get_email,
|
||||
# Calendar tools
|
||||
read_calendar,
|
||||
create_calendar_event,
|
||||
search_calendar,
|
||||
# Contacts tools
|
||||
create_contact,
|
||||
list_contacts,
|
||||
get_contact,
|
||||
# Gitea tools
|
||||
gitea_read_file_tool,
|
||||
gitea_list_files_tool,
|
||||
gitea_search_code_tool,
|
||||
gitea_get_tree_tool,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -540,6 +540,44 @@ class MemorySystem:
|
||||
|
||||
return sorted_results[:max_results]
|
||||
|
||||
def compact_conversation(self, user_message: str, assistant_response: str, tools_used: list = None) -> str:
|
||||
"""Create a compact summary of a conversation for memory storage.
|
||||
|
||||
Args:
|
||||
user_message: The user's input
|
||||
assistant_response: The assistant's full response
|
||||
tools_used: Optional list of tool names used (e.g., ['read_file', 'edit_file'])
|
||||
|
||||
Returns:
|
||||
Compact summary string
|
||||
"""
|
||||
# Extract file paths mentioned
|
||||
import re
|
||||
file_paths = re.findall(r'[a-zA-Z]:[\\\/][\w\\\/\-\.]+\.\w+|[\w\/\-\.]+\.(?:py|md|yaml|yml|json|txt|js|ts)', assistant_response)
|
||||
file_paths = list(set(file_paths))[:5] # Limit to 5 unique paths
|
||||
|
||||
# Truncate long responses
|
||||
if len(assistant_response) > 300:
|
||||
# Try to get first complete sentence or paragraph
|
||||
sentences = assistant_response.split('. ')
|
||||
if sentences and len(sentences[0]) < 200:
|
||||
summary = sentences[0] + '.'
|
||||
else:
|
||||
summary = assistant_response[:200] + '...'
|
||||
else:
|
||||
summary = assistant_response
|
||||
|
||||
# Build compact entry
|
||||
compact = f"**User**: {user_message}\n**Action**: {summary}"
|
||||
|
||||
if tools_used:
|
||||
compact += f"\n**Tools**: {', '.join(tools_used)}"
|
||||
|
||||
if file_paths:
|
||||
compact += f"\n**Files**: {', '.join(file_paths[:3])}" # Max 3 file paths
|
||||
|
||||
return compact
|
||||
|
||||
def write_memory(self, content: str, daily: bool = True) -> None:
|
||||
"""Write to memory file."""
|
||||
if daily:
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# MEMORY - Ajarbot Project Context
|
||||
|
||||
## Project
|
||||
Multi-platform AI agent with memory, cost-optimized for personal/small team use. Supports Slack, Telegram.
|
||||
|
||||
## Core Stack
|
||||
- **Memory**: Hybrid search (0.7 vector + 0.3 BM25), SQLite FTS5 + Markdown files
|
||||
- **Embeddings**: FastEmbed all-MiniLM-L6-v2 (384-dim, local, $0)
|
||||
- **LLM**: Claude (Haiku default, Sonnet w/ caching optional), GLM fallback
|
||||
- **Platforms**: Slack (Socket Mode), Telegram (polling)
|
||||
- **Tools**: File ops, shell commands (5 tools total)
|
||||
- **Monitoring**: Pulse & Brain (92% cheaper than Heartbeat - deprecated)
|
||||
|
||||
## Key Files
|
||||
- `agent.py` - Main agent (memory + LLM + tools)
|
||||
- `memory_system.py` - SQLite FTS5 + markdown sync
|
||||
- `llm_interface.py` - Claude/GLM API wrapper
|
||||
- `tools.py` - read_file, write_file, edit_file, list_directory, run_command
|
||||
- `bot_runner.py` - Multi-platform launcher
|
||||
- `scheduled_tasks.py` - Cron-like task scheduler
|
||||
|
||||
## Memory Files
|
||||
- `SOUL.md` - Agent personality (auto-loaded)
|
||||
- `MEMORY.md` - This file (project context)
|
||||
- `users/{username}.md` - Per-user preferences
|
||||
- `memory/YYYY-MM-DD.md` - Daily logs
|
||||
- `memory_index.db` - SQLite FTS5 index
|
||||
- `vectors.usearch` - Vector embeddings for semantic search
|
||||
|
||||
## Cost Optimizations (2026-02-13)
|
||||
**Target**: Minimize API costs while maintaining capability
|
||||
|
||||
### Active
|
||||
- Default: Haiku 4.5 ($0.25 input/$1.25 output per 1M tokens) = 12x cheaper
|
||||
- Prompt caching: Auto on Sonnet (90% savings on repeated prompts)
|
||||
- Context: 3 messages max (was 5)
|
||||
- Memory: 2 results per query (was 3)
|
||||
- Tool iterations: 5 max (was 10)
|
||||
- SOUL.md: 45 lines (was 87)
|
||||
|
||||
### Commands
|
||||
- `/haiku` - Switch to fast/cheap
|
||||
- `/sonnet` - Switch to smart/cached
|
||||
- `/status` - Show current config
|
||||
|
||||
### Results
|
||||
- Haiku: ~$0.001/message
|
||||
- Sonnet cached: ~$0.003/message (after first)
|
||||
- $5 free credits = hundreds of interactions
|
||||
|
||||
## Search System
|
||||
**IMPLEMENTED (2026-02-13)**: Hybrid semantic + keyword search
|
||||
- 0.7 vector similarity + 0.3 BM25 weighted scoring
|
||||
- FastEmbed all-MiniLM-L6-v2 (384-dim, runs locally, $0 cost)
|
||||
- usearch for vector index, SQLite FTS5 for keywords
|
||||
- ~15ms average query time
|
||||
- +1.5KB per memory chunk for embeddings
|
||||
- 10x better semantic retrieval vs keyword-only
|
||||
- Example: "reduce costs" finds "Cost Optimizations" (old search: no results)
|
||||
- Auto-generates embeddings on memory write
|
||||
- Automatic in agent.chat() - no user action needed
|
||||
|
||||
## Recent Changes
|
||||
**2026-02-13**: Hybrid search implemented
|
||||
- Added FastEmbed + usearch for semantic vector search
|
||||
- Upgraded from keyword-only to 0.7 vector + 0.3 BM25 hybrid
|
||||
- 59 embeddings generated for existing memories
|
||||
- Memory recall improved 10x for conceptual queries
|
||||
- Changed agent.py line 71: search() -> search_hybrid()
|
||||
- Zero cost (local embeddings, no API calls)
|
||||
|
||||
**2026-02-13**: Documentation cleanup
|
||||
- Removed 3 redundant docs (HEARTBEAT_HOOKS, QUICK_START_PULSE, MONITORING_COMPARISON)
|
||||
- Consolidated monitoring into PULSE_BRAIN.md
|
||||
- Updated README for accuracy
|
||||
- Sanitized repo (no API keys, user IDs committed)
|
||||
|
||||
**2026-02-13**: Tool system added
|
||||
- Bot can read/write/edit files, run commands autonomously
|
||||
- Integrated into SOUL.md instructions
|
||||
|
||||
**2026-02-13**: Task scheduler integrated
|
||||
- Morning weather task (6am daily to Telegram user 8088983654)
|
||||
- Config: `config/scheduled_tasks.yaml`
|
||||
|
||||
## Architecture Decisions
|
||||
- SQLite not Postgres: Simpler, adequate for personal bot
|
||||
- Haiku default: Cost optimization priority
|
||||
- Local embeddings (FastEmbed): Zero API calls, runs on device
|
||||
- Hybrid search (0.7 vector + 0.3 BM25): Best of both worlds
|
||||
- Markdown + DB: Simple, fast, no external deps
|
||||
- Tool use: Autonomous action without user copy/paste
|
||||
|
||||
## Deployment
|
||||
- Platform: Windows 11 primary
|
||||
- Git: https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
||||
- Config: `.env` for API keys, `config/adapters.local.yaml` for tokens (both gitignored)
|
||||
- Venv: Python 3.11+
|
||||
@@ -1,48 +0,0 @@
|
||||
# SOUL - Garvis Identity & Instructions
|
||||
|
||||
## Identity
|
||||
- **Name**: Garvis
|
||||
- **Email**: ramosgarvis@gmail.com (my account, used for Gmail API)
|
||||
- **Owner**: Jordan (see users/jordan.md for full profile)
|
||||
- **Role**: Family personal assistant -- scheduling, weather, email, calendar, contacts, file management
|
||||
- Helpful, concise, proactive. Value clarity and action over explanation.
|
||||
|
||||
## Critical Behaviors
|
||||
1. **Always check the user's profile** (users/{username}.md) before answering location/preference questions
|
||||
2. **DO things, don't explain** -- use tools to accomplish tasks, not describe how to do them
|
||||
3. **Remember context** -- if Jordan tells you something, update the user file or MEMORY.md
|
||||
4. **Use MST timezone** for all scheduling (Jordan is in Centennial, CO)
|
||||
|
||||
## Available Tools (17)
|
||||
### File & System
|
||||
- read_file, write_file, edit_file, list_directory, run_command
|
||||
|
||||
### Weather
|
||||
- get_weather (OpenWeatherMap API -- default location: Centennial, CO)
|
||||
|
||||
### Gmail (ramosgarvis@gmail.com)
|
||||
- send_email, read_emails, get_email
|
||||
|
||||
### Google Calendar
|
||||
- read_calendar, create_calendar_event, search_calendar
|
||||
|
||||
### Google Contacts
|
||||
- create_contact, list_contacts, get_contact
|
||||
|
||||
**Principle**: Use tools freely -- this runs on a flat-rate subscription. Be thorough.
|
||||
|
||||
## Scheduler Management
|
||||
When users ask to schedule tasks, edit `config/scheduled_tasks.yaml` directly.
|
||||
Schedule formats: `hourly`, `daily HH:MM`, `weekly day HH:MM`
|
||||
|
||||
## Memory System
|
||||
- SOUL.md: This file (identity + instructions)
|
||||
- MEMORY.md: Project context and important facts
|
||||
- users/{username}.md: Per-user preferences and info
|
||||
- memory/YYYY-MM-DD.md: Daily conversation logs
|
||||
|
||||
## Communication Style
|
||||
- Concise, action-oriented (Jordan has ADHD/scanner personality)
|
||||
- Break tasks into small chunks
|
||||
- Vary language to maintain interest
|
||||
- Frame suggestions as exploration opportunities, not obligations
|
||||
168
obsidian_mcp.py
Normal file
168
obsidian_mcp.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Obsidian MCP Server Integration.
|
||||
|
||||
Manages the external obsidian-mcp-server process and provides
|
||||
health checking, fallback routing, and configuration loading.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
import httpx
|
||||
|
||||
# Default config path
|
||||
_CONFIG_FILE = Path("config/obsidian_mcp.yaml")
|
||||
|
||||
# Cached state
|
||||
_obsidian_healthy: bool = False
|
||||
_last_health_check: float = 0.0
|
||||
_health_lock = threading.Lock()
|
||||
_config_cache: Optional[Dict] = None
|
||||
|
||||
|
||||
def load_obsidian_config() -> Dict[str, Any]:
|
||||
"""Load Obsidian MCP configuration with env var overrides."""
|
||||
global _config_cache
|
||||
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
|
||||
config = {}
|
||||
if _CONFIG_FILE.exists():
|
||||
with open(_CONFIG_FILE, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
obsidian = config.get("obsidian_mcp", {})
|
||||
|
||||
# Apply environment variable overrides
|
||||
env_overrides = {
|
||||
"OBSIDIAN_API_KEY": ("connection", "api_key"),
|
||||
"OBSIDIAN_BASE_URL": ("connection", "base_url"),
|
||||
"OBSIDIAN_MCP_ENABLED": None, # Special: top-level "enabled"
|
||||
"OBSIDIAN_ROUTING_STRATEGY": ("routing", "strategy"),
|
||||
"OBSIDIAN_VAULT_PATH": ("vault", "path"),
|
||||
}
|
||||
|
||||
for env_var, path in env_overrides.items():
|
||||
value = os.getenv(env_var)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if path is None:
|
||||
# Top-level key
|
||||
obsidian["enabled"] = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
section, key = path
|
||||
obsidian.setdefault(section, {})[key] = value
|
||||
|
||||
_config_cache = obsidian
|
||||
return obsidian
|
||||
|
||||
|
||||
def is_obsidian_enabled() -> bool:
|
||||
"""Check if Obsidian MCP integration is enabled in config."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("enabled", False)
|
||||
|
||||
|
||||
def check_obsidian_health(force: bool = False) -> bool:
|
||||
"""Check if Obsidian REST API is reachable.
|
||||
|
||||
Uses cached result unless force=True or cache has expired.
|
||||
Thread-safe.
|
||||
"""
|
||||
global _obsidian_healthy, _last_health_check
|
||||
|
||||
config = load_obsidian_config()
|
||||
check_interval = config.get("routing", {}).get("health_check_interval", 60)
|
||||
timeout = config.get("routing", {}).get("api_timeout", 10)
|
||||
|
||||
with _health_lock:
|
||||
now = time.time()
|
||||
if not force and (now - _last_health_check) < check_interval:
|
||||
return _obsidian_healthy
|
||||
|
||||
base_url = config.get("connection", {}).get(
|
||||
"base_url", "http://127.0.0.1:27123"
|
||||
)
|
||||
api_key = config.get("connection", {}).get("api_key", "")
|
||||
|
||||
try:
|
||||
# Obsidian Local REST API health endpoint
|
||||
response = httpx.get(
|
||||
f"{base_url}/",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=timeout,
|
||||
verify=config.get("connection", {}).get("verify_ssl", False),
|
||||
)
|
||||
_obsidian_healthy = response.status_code == 200
|
||||
except Exception:
|
||||
_obsidian_healthy = False
|
||||
|
||||
_last_health_check = now
|
||||
return _obsidian_healthy
|
||||
|
||||
|
||||
def get_obsidian_server_config() -> Dict[str, Any]:
|
||||
"""Build the MCP server configuration for Agent SDK registration.
|
||||
|
||||
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||
The obsidian-mcp-server runs as a stdio subprocess.
|
||||
"""
|
||||
config = load_obsidian_config()
|
||||
connection = config.get("connection", {})
|
||||
vault = config.get("vault", {})
|
||||
cache = config.get("cache", {})
|
||||
logging = config.get("logging", {})
|
||||
|
||||
env = {
|
||||
"OBSIDIAN_API_KEY": connection.get("api_key", ""),
|
||||
"OBSIDIAN_BASE_URL": connection.get(
|
||||
"base_url", "http://127.0.0.1:27123"
|
||||
),
|
||||
"OBSIDIAN_VERIFY_SSL": str(
|
||||
connection.get("verify_ssl", False)
|
||||
).lower(),
|
||||
"OBSIDIAN_VAULT_PATH": str(vault.get("path", "")),
|
||||
"OBSIDIAN_ENABLE_CACHE": str(
|
||||
cache.get("enabled", True)
|
||||
).lower(),
|
||||
"OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN": str(
|
||||
cache.get("refresh_interval_min", 10)
|
||||
),
|
||||
"MCP_LOG_LEVEL": logging.get("level", "info"),
|
||||
}
|
||||
|
||||
return {
|
||||
"command": "npx",
|
||||
"args": ["obsidian-mcp-server"],
|
||||
"env": env,
|
||||
}
|
||||
|
||||
|
||||
def get_routing_strategy() -> str:
|
||||
"""Get the configured tool routing strategy."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("routing", {}).get("strategy", "obsidian_preferred")
|
||||
|
||||
|
||||
def should_fallback_to_custom() -> bool:
|
||||
"""Check if fallback to custom tools is enabled."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("routing", {}).get("fallback_to_custom", True)
|
||||
|
||||
|
||||
# List of all Obsidian MCP tool names
|
||||
OBSIDIAN_TOOLS = [
|
||||
"obsidian_read_note",
|
||||
"obsidian_update_note",
|
||||
"obsidian_search_replace",
|
||||
"obsidian_global_search",
|
||||
"obsidian_list_notes",
|
||||
"obsidian_manage_frontmatter",
|
||||
"obsidian_manage_tags",
|
||||
"obsidian_delete_note",
|
||||
]
|
||||
1023
scripts/collect-homelab-config.sh
Normal file
1023
scripts/collect-homelab-config.sh
Normal file
File diff suppressed because it is too large
Load Diff
416
scripts/collect-remote.sh
Normal file
416
scripts/collect-remote.sh
Normal file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
################################################################################
|
||||
# Remote Homelab Collection Wrapper
|
||||
# Purpose: Executes the collection script on a remote Proxmox host via SSH
|
||||
# and retrieves the results back to your local machine (WSL/Linux)
|
||||
#
|
||||
# Usage: ./collect-remote.sh [PROXMOX_HOST] [OPTIONS]
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Color codes
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Script configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COLLECTION_SCRIPT="${SCRIPT_DIR}/collect-homelab-config.sh"
|
||||
REMOTE_SCRIPT_PATH="/tmp/collect-homelab-config.sh"
|
||||
LOCAL_OUTPUT_DIR="${SCRIPT_DIR}"
|
||||
|
||||
# SSH configuration
|
||||
SSH_USER="${SSH_USER:-root}"
|
||||
SSH_PORT="${SSH_PORT:-22}"
|
||||
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=no"
|
||||
|
||||
################################################################################
|
||||
# Functions
|
||||
################################################################################
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="$*"
|
||||
|
||||
case "${level}" in
|
||||
INFO)
|
||||
echo -e "${BLUE}[INFO]${NC} ${message}"
|
||||
;;
|
||||
SUCCESS)
|
||||
echo -e "${GREEN}[✓]${NC} ${message}"
|
||||
;;
|
||||
WARN)
|
||||
echo -e "${YELLOW}[WARN]${NC} ${message}"
|
||||
;;
|
||||
ERROR)
|
||||
echo -e "${RED}[ERROR]${NC} ${message}" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
banner() {
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||
echo -e "${BOLD}${CYAN} $1${NC}"
|
||||
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
${BOLD}Remote Homelab Collection Wrapper${NC}
|
||||
|
||||
${BOLD}USAGE:${NC}
|
||||
$0 PROXMOX_HOST [OPTIONS]
|
||||
|
||||
${BOLD}DESCRIPTION:${NC}
|
||||
Executes the homelab collection script on a remote Proxmox host via SSH,
|
||||
then retrieves the results back to your local machine.
|
||||
|
||||
${BOLD}ARGUMENTS:${NC}
|
||||
PROXMOX_HOST IP address or hostname of your Proxmox server
|
||||
|
||||
${BOLD}OPTIONS:${NC}
|
||||
-u, --user USER SSH username (default: root)
|
||||
-p, --port PORT SSH port (default: 22)
|
||||
-l, --level LEVEL Collection level: basic, standard, full, paranoid
|
||||
(default: standard)
|
||||
-s, --sanitize OPT Sanitization: all, ips, none (default: passwords/tokens only)
|
||||
-o, --output DIR Local directory to store results (default: current directory)
|
||||
-k, --keep-remote Keep the export on the remote host (default: remove after download)
|
||||
-v, --verbose Verbose output
|
||||
-h, --help Show this help message
|
||||
|
||||
${BOLD}ENVIRONMENT VARIABLES:${NC}
|
||||
SSH_USER Default SSH username (default: root)
|
||||
SSH_PORT Default SSH port (default: 22)
|
||||
|
||||
${BOLD}EXAMPLES:${NC}
|
||||
# Basic usage
|
||||
$0 192.168.1.100
|
||||
|
||||
# Full collection with complete sanitization
|
||||
$0 192.168.1.100 --level full --sanitize all
|
||||
|
||||
# Custom SSH user and port
|
||||
$0 proxmox.local --user admin --port 2222
|
||||
|
||||
# Keep results on remote host
|
||||
$0 192.168.1.100 --keep-remote
|
||||
|
||||
# Verbose output with custom output directory
|
||||
$0 192.168.1.100 -v -o ~/backups/homelab
|
||||
|
||||
${BOLD}PREREQUISITES:${NC}
|
||||
1. SSH access to the Proxmox host
|
||||
2. collect-homelab-config.sh in the same directory as this script
|
||||
3. Sufficient disk space on both remote and local machines
|
||||
|
||||
${BOLD}WORKFLOW:${NC}
|
||||
1. Copies collection script to remote Proxmox host
|
||||
2. Executes the script remotely
|
||||
3. Downloads the compressed archive to local machine
|
||||
4. Optionally removes the remote copy
|
||||
5. Extracts the archive locally
|
||||
|
||||
${BOLD}NOTES:${NC}
|
||||
- Requires passwordless SSH or SSH key authentication (recommended)
|
||||
- The script will be run as the specified SSH user (typically root)
|
||||
- Remote execution output is displayed in real-time
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
check_prerequisites() {
|
||||
# Check if collection script exists
|
||||
if [[ ! -f "${COLLECTION_SCRIPT}" ]]; then
|
||||
log ERROR "Collection script not found: ${COLLECTION_SCRIPT}"
|
||||
log ERROR "Ensure collect-homelab-config.sh is in the same directory as this script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if ssh is available
|
||||
if ! command -v ssh &> /dev/null; then
|
||||
log ERROR "SSH client not found. Please install openssh-client"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if scp is available
|
||||
if ! command -v scp &> /dev/null; then
|
||||
log ERROR "SCP not found. Please install openssh-client"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
test_ssh_connection() {
|
||||
local host="$1"
|
||||
|
||||
log INFO "Testing SSH connection to ${SSH_USER}@${host}:${SSH_PORT}..."
|
||||
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "exit 0" 2>/dev/null; then
|
||||
log SUCCESS "SSH connection successful"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Cannot connect to ${SSH_USER}@${host}:${SSH_PORT}"
|
||||
log ERROR "Possible issues:"
|
||||
log ERROR " - Host is unreachable"
|
||||
log ERROR " - SSH service is not running"
|
||||
log ERROR " - Incorrect credentials"
|
||||
log ERROR " - Firewall blocking connection"
|
||||
log ERROR ""
|
||||
log ERROR "Try manually: ssh -p ${SSH_PORT} ${SSH_USER}@${host}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_proxmox_host() {
|
||||
local host="$1"
|
||||
|
||||
log INFO "Verifying Proxmox installation on remote host..."
|
||||
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "test -f /etc/pve/.version" 2>/dev/null; then
|
||||
local pve_version=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "cat /etc/pve/.version" 2>/dev/null)
|
||||
log SUCCESS "Confirmed Proxmox VE installation (version: ${pve_version})"
|
||||
return 0
|
||||
else
|
||||
log WARN "Remote host does not appear to be a Proxmox VE server"
|
||||
log WARN "Proceeding anyway, but collection may fail..."
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
upload_script() {
|
||||
local host="$1"
|
||||
|
||||
banner "Uploading Collection Script"
|
||||
|
||||
log INFO "Copying collection script to ${host}..."
|
||||
|
||||
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${COLLECTION_SCRIPT}" "${SSH_USER}@${host}:${REMOTE_SCRIPT_PATH}"; then
|
||||
log SUCCESS "Script uploaded successfully"
|
||||
|
||||
# Make executable
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "chmod +x ${REMOTE_SCRIPT_PATH}"
|
||||
log SUCCESS "Script permissions set"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Failed to upload script"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
execute_remote_collection() {
|
||||
local host="$1"
|
||||
shift
|
||||
local collection_args=("$@")
|
||||
|
||||
banner "Executing Collection on Remote Host"
|
||||
|
||||
log INFO "Running collection script on ${host}..."
|
||||
log INFO "Arguments: ${collection_args[*]}"
|
||||
|
||||
# Build the remote command
|
||||
local remote_cmd="${REMOTE_SCRIPT_PATH} ${collection_args[*]}"
|
||||
|
||||
# Execute remotely and stream output
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "${remote_cmd}"; then
|
||||
log SUCCESS "Collection completed successfully on remote host"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Collection failed on remote host"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_results() {
|
||||
local host="$1"
|
||||
local output_dir="$2"
|
||||
|
||||
banner "Downloading Results"
|
||||
|
||||
log INFO "Finding remote export archive..."
|
||||
|
||||
# Find the most recent export archive
|
||||
local remote_archive=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||
"ls -t /root/homelab-export-*.tar.gz 2>/dev/null | head -1" 2>/dev/null)
|
||||
|
||||
if [[ -z "${remote_archive}" ]]; then
|
||||
log ERROR "No export archive found on remote host"
|
||||
log ERROR "Collection may have failed or compression was disabled"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log INFO "Found archive: ${remote_archive}"
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
# Download the archive
|
||||
local local_archive="${output_dir}/$(basename "${remote_archive}")"
|
||||
|
||||
log INFO "Downloading to: ${local_archive}"
|
||||
|
||||
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${SSH_USER}@${host}:${remote_archive}" "${local_archive}"; then
|
||||
log SUCCESS "Archive downloaded successfully"
|
||||
|
||||
# Extract the archive
|
||||
log INFO "Extracting archive..."
|
||||
if tar -xzf "${local_archive}" -C "${output_dir}"; then
|
||||
log SUCCESS "Archive extracted to: ${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||
|
||||
# Show summary
|
||||
local extracted_dir="${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||
if [[ -f "${extracted_dir}/SUMMARY.md" ]]; then
|
||||
echo ""
|
||||
log INFO "Collection Summary:"
|
||||
echo ""
|
||||
head -30 "${extracted_dir}/SUMMARY.md"
|
||||
echo ""
|
||||
log INFO "Full summary: ${extracted_dir}/SUMMARY.md"
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
log ERROR "Failed to extract archive"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log ERROR "Failed to download archive"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_remote() {
|
||||
local host="$1"
|
||||
local keep_remote="$2"
|
||||
|
||||
if [[ "${keep_remote}" == "true" ]]; then
|
||||
log INFO "Keeping export on remote host (--keep-remote specified)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
banner "Cleaning Up Remote Host"
|
||||
|
||||
log INFO "Removing export files from remote host..."
|
||||
|
||||
# Remove the script
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "rm -f ${REMOTE_SCRIPT_PATH}" 2>/dev/null || true
|
||||
|
||||
# Remove export directories and archives
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||
"rm -rf /root/homelab-export-* 2>/dev/null" 2>/dev/null || true
|
||||
|
||||
log SUCCESS "Remote cleanup completed"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main Execution
|
||||
################################################################################
|
||||
|
||||
main() {
|
||||
# Parse arguments
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local proxmox_host=""
|
||||
local collection_level="standard"
|
||||
local sanitize_option=""
|
||||
local keep_remote="false"
|
||||
local verbose="false"
|
||||
|
||||
# First argument is the host
|
||||
proxmox_host="$1"
|
||||
shift
|
||||
|
||||
# Parse remaining options
|
||||
local collection_args=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-u|--user)
|
||||
SSH_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--port)
|
||||
SSH_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--level)
|
||||
collection_level="$2"
|
||||
collection_args+=("--level" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-s|--sanitize)
|
||||
sanitize_option="$2"
|
||||
collection_args+=("--sanitize" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
LOCAL_OUTPUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-k|--keep-remote)
|
||||
keep_remote="true"
|
||||
shift
|
||||
;;
|
||||
-v|--verbose)
|
||||
verbose="true"
|
||||
collection_args+=("--verbose")
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log ERROR "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate host
|
||||
if [[ -z "${proxmox_host}" ]]; then
|
||||
log ERROR "Proxmox host not specified"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Display configuration
|
||||
banner "Remote Homelab Collection"
|
||||
echo -e "${BOLD}Target Host:${NC} ${proxmox_host}"
|
||||
echo -e "${BOLD}SSH User:${NC} ${SSH_USER}"
|
||||
echo -e "${BOLD}SSH Port:${NC} ${SSH_PORT}"
|
||||
echo -e "${BOLD}Collection Level:${NC} ${collection_level}"
|
||||
echo -e "${BOLD}Output Directory:${NC} ${LOCAL_OUTPUT_DIR}"
|
||||
echo -e "${BOLD}Keep Remote:${NC} ${keep_remote}"
|
||||
echo ""
|
||||
|
||||
# Execute workflow
|
||||
check_prerequisites
|
||||
test_ssh_connection "${proxmox_host}" || exit 1
|
||||
verify_proxmox_host "${proxmox_host}"
|
||||
upload_script "${proxmox_host}" || exit 1
|
||||
execute_remote_collection "${proxmox_host}" "${collection_args[@]}" || exit 1
|
||||
download_results "${proxmox_host}" "${LOCAL_OUTPUT_DIR}" || exit 1
|
||||
cleanup_remote "${proxmox_host}" "${keep_remote}"
|
||||
|
||||
banner "Collection Complete"
|
||||
|
||||
log SUCCESS "Homelab infrastructure export completed successfully"
|
||||
log INFO "Results are available in: ${LOCAL_OUTPUT_DIR}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
152
scripts/collection_output.txt
Normal file
152
scripts/collection_output.txt
Normal file
@@ -0,0 +1,152 @@
|
||||
=== COLLECTION OUTPUT ===
|
||||
|
||||
================================================================================
|
||||
Starting Homelab Infrastructure Collection
|
||||
================================================================================
|
||||
|
||||
[INFO] Collection Level: full
|
||||
[INFO] Output Directory: /tmp/homelab-export
|
||||
[INFO] Sanitization: IPs=false | Passwords=false | Tokens=false
|
||||
|
||||
================================================================================
|
||||
Creating Directory Structure
|
||||
================================================================================
|
||||
|
||||
[✓] Directory structure created at: /tmp/homelab-export
|
||||
|
||||
================================================================================
|
||||
Collecting System Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Proxmox VE version
|
||||
[✓] Collected Hostname
|
||||
[✓] Collected Kernel information
|
||||
[✓] Collected System uptime
|
||||
[✓] Collected System date/time
|
||||
[✓] Collected CPU information
|
||||
[✓] Collected Detailed CPU info
|
||||
[✓] Collected Memory information
|
||||
[✓] Collected Detailed memory info
|
||||
[✓] Collected Filesystem usage
|
||||
[✓] Collected Block devices
|
||||
[✓] Collected LVM physical volumes
|
||||
[✓] Collected LVM volume groups
|
||||
[✓] Collected LVM logical volumes
|
||||
[✓] Collected IP addresses
|
||||
[✓] Collected Routing table
|
||||
[✓] Collected Listening sockets
|
||||
[✓] Collected Installed packages
|
||||
|
||||
================================================================================
|
||||
Collecting Proxmox Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Datacenter config
|
||||
[✓] Collected Storage config
|
||||
[✓] Collected User config
|
||||
[✓] Collected Auth public key
|
||||
[WARN] Failed to copy directory HA configuration from /etc/pve/ha
|
||||
|
||||
================================================================================
|
||||
Collecting VM Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected VM 100 (docker-hub) config
|
||||
[✓] Collected VM 101 (monitoring-docker) config
|
||||
[✓] Collected VM 104 (ubuntu-dev) config
|
||||
[✓] Collected VM 105 (pfSense-Firewall) config
|
||||
[✓] Collected VM 106 (Ansible-Control) config
|
||||
[✓] Collected VM 107 (ubuntu-docker) config
|
||||
[✓] Collected VM 108 (CML) config
|
||||
[✓] Collected VM 114 (haos) config
|
||||
[✓] Collected VM 119 (moltbot) config
|
||||
|
||||
================================================================================
|
||||
Collecting LXC Container Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Container 102 (nginx) config
|
||||
[✓] Collected Container 103 (netbox) config
|
||||
[✓] Collected Container 112 (twingate-connector) config
|
||||
[✓] Collected Container 113 (n8n
|
||||
n8n
|
||||
n8n) config
|
||||
[✓] Collected Container 117 (test-cve-database) config
|
||||
|
||||
================================================================================
|
||||
Collecting Network Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Network interfaces config
|
||||
[WARN] Failed to copy directory Additional interface configs from /etc/network/interfaces.d
|
||||
[✓] Collected SDN configuration
|
||||
[✓] Collected Hosts file
|
||||
[✓] Collected DNS resolver config
|
||||
|
||||
================================================================================
|
||||
Collecting Storage Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Storage status
|
||||
[✓] Collected ZFS pool status
|
||||
[✓] Collected ZFS pool list
|
||||
[✓] Collected ZFS datasets
|
||||
[✓] Collected Samba config
|
||||
[✓] Collected iSCSI initiator config
|
||||
|
||||
================================================================================
|
||||
Collecting Backup Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Vzdump config
|
||||
|
||||
================================================================================
|
||||
Collecting Cluster Information
|
||||
================================================================================
|
||||
|
||||
[WARN] Failed to execute: pvecm status (Cluster status)
|
||||
[WARN] Failed to execute: pvecm nodes (Cluster nodes)
|
||||
[✓] Collected Cluster resources
|
||||
[✓] Collected Recent tasks
|
||||
|
||||
================================================================================
|
||||
Collecting Guest Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected VM list
|
||||
[✓] Collected Container list
|
||||
[✓] Collected All guests (JSON)
|
||||
|
||||
================================================================================
|
||||
Collecting Service Configurations (Advanced)
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Systemd services
|
||||
|
||||
================================================================================
|
||||
Generating Documentation
|
||||
================================================================================
|
||||
|
||||
[✓] Generated README.md
|
||||
|
||||
================================================================================
|
||||
Generating Summary Report
|
||||
================================================================================
|
||||
|
||||
[✓] Generated SUMMARY.md
|
||||
|
||||
================================================================================
|
||||
Collection Complete
|
||||
================================================================================
|
||||
|
||||
[✓] Total items collected: 53
|
||||
[INFO] Total items skipped: 1
|
||||
[WARN] Total errors: 4
|
||||
[WARN] Review /tmp/homelab-export/collection.log for details
|
||||
|
||||
Export Location: /tmp/homelab-export
|
||||
Summary Report: /tmp/homelab-export/SUMMARY.md
|
||||
Collection Log: /tmp/homelab-export/collection.log
|
||||
|
||||
|
||||
Exit code: 0
|
||||
53
scripts/proxmox_ssh.py
Normal file
53
scripts/proxmox_ssh.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||
Uses paramiko for native Python SSH (no sshpass needed).
|
||||
Usage: python proxmox_ssh.py "command to run"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import paramiko
|
||||
|
||||
PROXMOX_HOST = "192.168.2.100"
|
||||
PROXMOX_USER = "root"
|
||||
PROXMOX_PASS = "Nbkx4mdmay1)"
|
||||
PROXMOX_PORT = 22
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
def run_command(command: str) -> tuple:
|
||||
"""Execute a command on the Proxmox server via SSH.
|
||||
Returns (stdout, stderr, exit_code).
|
||||
"""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
client.connect(
|
||||
hostname=PROXMOX_HOST,
|
||||
port=PROXMOX_PORT,
|
||||
username=PROXMOX_USER,
|
||||
password=PROXMOX_PASS,
|
||||
timeout=TIMEOUT,
|
||||
look_for_keys=False,
|
||||
allow_agent=False,
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(command, timeout=TIMEOUT)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
return out, err, exit_code
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python proxmox_ssh.py \"command\"")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
out, err, code = run_command(cmd)
|
||||
if out:
|
||||
print(out, end="")
|
||||
if err:
|
||||
print(err, end="", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
9
scripts/proxmox_ssh.sh
Normal file
9
scripts/proxmox_ssh.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||
# Usage: proxmox_ssh.sh "command to run"
|
||||
|
||||
PROXMOX_HOST="192.168.2.100"
|
||||
PROXMOX_USER="root"
|
||||
PROXMOX_PASS="Nbkx4mdmay1)"
|
||||
|
||||
sshpass -p "$PROXMOX_PASS" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${PROXMOX_USER}@${PROXMOX_HOST}" "$1"
|
||||
@@ -1,311 +0,0 @@
|
||||
"""Test script for Agent SDK implementation.
|
||||
|
||||
This script tests the Agent SDK integration without running the full bot.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure we're testing the Agent SDK mode
|
||||
os.environ["USE_AGENT_SDK"] = "true"
|
||||
os.environ["USE_DIRECT_API"] = "false"
|
||||
os.environ["USE_CLAUDE_CODE_SERVER"] = "false"
|
||||
|
||||
def test_llm_interface_initialization():
|
||||
"""Test 1: LLMInterface initialization with Agent SDK."""
|
||||
print("\n=== Test 1: LLMInterface Initialization ===")
|
||||
try:
|
||||
from llm_interface import LLMInterface
|
||||
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
print(f"✓ LLMInterface created successfully")
|
||||
print(f" - Provider: {llm.provider}")
|
||||
print(f" - Mode: {llm.mode}")
|
||||
print(f" - Model: {llm.model}")
|
||||
print(f" - Agent SDK available: {llm.agent_sdk is not None}")
|
||||
|
||||
if llm.mode != "agent_sdk":
|
||||
print(f"✗ WARNING: Expected mode 'agent_sdk', got '{llm.mode}'")
|
||||
if llm.mode == "direct_api":
|
||||
print(" - This likely means claude-agent-sdk is not installed")
|
||||
print(" - Run: pip install claude-agent-sdk")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_simple_chat():
|
||||
"""Test 2: Simple chat without tools."""
|
||||
print("\n=== Test 2: Simple Chat (No Tools) ===")
|
||||
try:
|
||||
from llm_interface import LLMInterface
|
||||
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
if llm.mode != "agent_sdk":
|
||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
||||
return False
|
||||
|
||||
print("Sending simple chat message...")
|
||||
messages = [
|
||||
{"role": "user", "content": "Say 'Hello from Agent SDK!' in exactly those words."}
|
||||
]
|
||||
|
||||
response = llm.chat(messages, system="You are a helpful assistant.", max_tokens=100)
|
||||
|
||||
print(f"✓ Chat completed successfully")
|
||||
print(f" - Response: {response[:100]}...")
|
||||
print(f" - Response type: {type(response)}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_chat_with_tools():
|
||||
"""Test 3: Chat with tools (message format compatibility)."""
|
||||
print("\n=== Test 3: Chat with Tools ===")
|
||||
try:
|
||||
from llm_interface import LLMInterface
|
||||
from tools import TOOL_DEFINITIONS
|
||||
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
if llm.mode != "agent_sdk":
|
||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
||||
return False
|
||||
|
||||
print("Sending chat message with tool definitions...")
|
||||
messages = [
|
||||
{"role": "user", "content": "What is 2+2? Just respond with the number, don't use any tools."}
|
||||
]
|
||||
|
||||
response = llm.chat_with_tools(
|
||||
messages,
|
||||
tools=TOOL_DEFINITIONS,
|
||||
system="You are a helpful assistant.",
|
||||
max_tokens=100
|
||||
)
|
||||
|
||||
print(f"✓ Chat with tools completed successfully")
|
||||
print(f" - Response type: {type(response)}")
|
||||
print(f" - Has .content: {hasattr(response, 'content')}")
|
||||
print(f" - Has .stop_reason: {hasattr(response, 'stop_reason')}")
|
||||
print(f" - Has .usage: {hasattr(response, 'usage')}")
|
||||
print(f" - Stop reason: {response.stop_reason}")
|
||||
|
||||
if hasattr(response, 'content') and response.content:
|
||||
print(f" - Content blocks: {len(response.content)}")
|
||||
for i, block in enumerate(response.content):
|
||||
print(f" - Block {i}: {type(block).__name__}")
|
||||
if hasattr(block, 'type'):
|
||||
print(f" - Type: {block.type}")
|
||||
if hasattr(block, 'text'):
|
||||
print(f" - Text: {block.text[:50]}...")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_response_format_compatibility():
|
||||
"""Test 4: Verify response format matches what agent.py expects."""
|
||||
print("\n=== Test 4: Response Format Compatibility ===")
|
||||
try:
|
||||
from llm_interface import LLMInterface
|
||||
from anthropic.types import TextBlock, ToolUseBlock
|
||||
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
if llm.mode != "agent_sdk":
|
||||
print(f"⊘ Skipping test (mode is '{llm.mode}', not 'agent_sdk')")
|
||||
return False
|
||||
|
||||
# Simulate SDK response
|
||||
mock_sdk_response = {
|
||||
"content": [
|
||||
{"type": "text", "text": "Test response"}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5
|
||||
},
|
||||
"id": "test_message_id",
|
||||
"model": "claude-haiku-4-5-20251001"
|
||||
}
|
||||
|
||||
print("Converting mock SDK response to Message format...")
|
||||
message = llm._convert_sdk_response_to_message(mock_sdk_response)
|
||||
|
||||
print(f"✓ Conversion successful")
|
||||
print(f" - Message type: {type(message).__name__}")
|
||||
print(f" - Has content: {hasattr(message, 'content')}")
|
||||
print(f" - Has stop_reason: {hasattr(message, 'stop_reason')}")
|
||||
print(f" - Has usage: {hasattr(message, 'usage')}")
|
||||
print(f" - Content[0] type: {type(message.content[0]).__name__}")
|
||||
print(f" - Content[0].type: {message.content[0].type}")
|
||||
print(f" - Content[0].text: {message.content[0].text}")
|
||||
print(f" - Stop reason: {message.stop_reason}")
|
||||
print(f" - Usage.input_tokens: {message.usage.input_tokens}")
|
||||
print(f" - Usage.output_tokens: {message.usage.output_tokens}")
|
||||
|
||||
# Verify all required attributes exist
|
||||
required_attrs = ['content', 'stop_reason', 'usage', 'id', 'model', 'role', 'type']
|
||||
missing_attrs = [attr for attr in required_attrs if not hasattr(message, attr)]
|
||||
|
||||
if missing_attrs:
|
||||
print(f"✗ Missing attributes: {missing_attrs}")
|
||||
return False
|
||||
|
||||
print(f"✓ All required attributes present")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_mode_selection():
|
||||
"""Test 5: Verify mode selection logic."""
|
||||
print("\n=== Test 5: Mode Selection Logic ===")
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "Default (Agent SDK)",
|
||||
"env": {},
|
||||
"expected": "agent_sdk"
|
||||
},
|
||||
{
|
||||
"name": "Explicit Direct API",
|
||||
"env": {"USE_DIRECT_API": "true"},
|
||||
"expected": "direct_api"
|
||||
},
|
||||
{
|
||||
"name": "Legacy Server",
|
||||
"env": {"USE_CLAUDE_CODE_SERVER": "true"},
|
||||
"expected": "legacy_server"
|
||||
},
|
||||
{
|
||||
"name": "Priority: Direct API > Agent SDK",
|
||||
"env": {"USE_DIRECT_API": "true", "USE_AGENT_SDK": "true"},
|
||||
"expected": "direct_api"
|
||||
},
|
||||
{
|
||||
"name": "Priority: Legacy > Agent SDK",
|
||||
"env": {"USE_CLAUDE_CODE_SERVER": "true", "USE_AGENT_SDK": "true"},
|
||||
"expected": "legacy_server"
|
||||
}
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
|
||||
for test_case in test_cases:
|
||||
print(f"\n Testing: {test_case['name']}")
|
||||
|
||||
# Save current env
|
||||
old_env = {}
|
||||
for key in ["USE_DIRECT_API", "USE_CLAUDE_CODE_SERVER", "USE_AGENT_SDK"]:
|
||||
old_env[key] = os.environ.get(key)
|
||||
|
||||
# Set test env
|
||||
for key in old_env.keys():
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
for key, value in test_case["env"].items():
|
||||
os.environ[key] = value
|
||||
|
||||
# Force reimport to pick up new env vars
|
||||
if 'llm_interface' in sys.modules:
|
||||
del sys.modules['llm_interface']
|
||||
|
||||
try:
|
||||
from llm_interface import LLMInterface
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
if llm.mode == test_case["expected"]:
|
||||
print(f" ✓ Correct mode: {llm.mode}")
|
||||
else:
|
||||
print(f" ✗ Wrong mode: expected '{test_case['expected']}', got '{llm.mode}'")
|
||||
all_passed = False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Restore env
|
||||
for key in old_env.keys():
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
if old_env[key] is not None:
|
||||
os.environ[key] = old_env[key]
|
||||
|
||||
# Force reimport one more time to reset
|
||||
if 'llm_interface' in sys.modules:
|
||||
del sys.modules['llm_interface']
|
||||
|
||||
return all_passed
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests."""
|
||||
print("=" * 70)
|
||||
print("AGENT SDK IMPLEMENTATION TEST SUITE")
|
||||
print("=" * 70)
|
||||
|
||||
tests = [
|
||||
("Initialization", test_llm_interface_initialization),
|
||||
("Simple Chat", test_simple_chat),
|
||||
("Chat with Tools", test_chat_with_tools),
|
||||
("Response Format", test_response_format_compatibility),
|
||||
("Mode Selection", test_mode_selection),
|
||||
]
|
||||
|
||||
results = {}
|
||||
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
results[name] = test_func()
|
||||
except Exception as e:
|
||||
print(f"\n✗ Test '{name}' crashed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
results[name] = False
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
for name, passed in results.items():
|
||||
status = "✓ PASS" if passed else "✗ FAIL"
|
||||
print(f"{status:8} {name}")
|
||||
|
||||
passed_count = sum(1 for p in results.values() if p)
|
||||
total_count = len(results)
|
||||
|
||||
print(f"\nTotal: {passed_count}/{total_count} tests passed")
|
||||
|
||||
if passed_count == total_count:
|
||||
print("\n🎉 All tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n⚠ {total_count - passed_count} test(s) failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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