Compare commits
6 Commits
fe7c146dc6
...
eb0f543366
| Author | SHA1 | Date | |
|---|---|---|---|
| eb0f543366 | |||
| 6d62fa6314 | |||
| 7697220c74 | |||
| bb86a9eef5 | |||
| 58de3e55dc | |||
| a9efdc0a01 |
50
.env.example
50
.env.example
@@ -35,6 +35,56 @@ AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token
|
|||||||
# Get token from: https://t.me/BotFather
|
# Get token from: https://t.me/BotFather
|
||||||
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# SSH Access (Optional)
|
||||||
|
# ========================================
|
||||||
|
# Proxmox SSH credentials for remote management
|
||||||
|
PROXMOX_SSH_HOST=192.168.2.100
|
||||||
|
PROXMOX_SSH_USER=root
|
||||||
|
PROXMOX_SSH_PORT=22
|
||||||
|
|
||||||
|
# Authentication: Use EITHER password OR key (key is more secure)
|
||||||
|
# Option 1: Password-based (easier but less secure)
|
||||||
|
PROXMOX_SSH_PASSWORD=your-proxmox-password
|
||||||
|
|
||||||
|
# Option 2: Key-based (recommended for security)
|
||||||
|
# PROXMOX_SSH_KEY_FILE=C:/Users/YourName/.ssh/id_rsa
|
||||||
|
# Generate key: ssh-keygen -t rsa -b 4096
|
||||||
|
# Copy to Proxmox: ssh-copy-id root@192.168.2.100
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Cloudflare MCP Integration (Optional)
|
||||||
|
# ========================================
|
||||||
|
# Cloudflare Code Mode MCP server exposes the entire Cloudflare API
|
||||||
|
# See: mcp_servers/cloudflare/config.py for details
|
||||||
|
|
||||||
|
# Enable/disable Cloudflare MCP integration
|
||||||
|
CLOUDFLARE_MCP_ENABLED=false
|
||||||
|
|
||||||
|
# Cloudflare API Token (create at https://dash.cloudflare.com/profile/api-tokens)
|
||||||
|
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here
|
||||||
|
|
||||||
|
# Cloudflare MCP remote server URL (default: https://mcp.cloudflare.com/mcp)
|
||||||
|
# CLOUDFLARE_MCP_URL=https://mcp.cloudflare.com/mcp
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Loki MCP Integration (Optional)
|
||||||
|
# ========================================
|
||||||
|
# Loki MCP server provides log querying and analysis via Loki HTTP API
|
||||||
|
# See: mcp_servers/loki/config.py for details
|
||||||
|
|
||||||
|
# Enable/disable Loki MCP integration
|
||||||
|
LOKI_MCP_ENABLED=false
|
||||||
|
|
||||||
|
# Loki instance URL (via reverse proxy)
|
||||||
|
LOKI_URL=https://loki.apophisnetworking.net
|
||||||
|
|
||||||
|
# Request timeout in seconds (default: 30)
|
||||||
|
# LOKI_TIMEOUT=30
|
||||||
|
|
||||||
|
# Default number of log lines to return (default: 100)
|
||||||
|
# LOKI_DEFAULT_LIMIT=100
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# Obsidian MCP Integration (Optional)
|
# Obsidian MCP Integration (Optional)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -42,18 +42,26 @@ Thumbs.db
|
|||||||
*.local.json
|
*.local.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
# Contains Proxmox root password (legacy)
|
||||||
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
scripts/proxmox_ssh.sh
|
||||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
# Contains Proxmox root password (paramiko)
|
||||||
|
scripts/proxmox_ssh.py
|
||||||
|
# Use scheduled_tasks.example.yaml instead
|
||||||
|
config/scheduled_tasks.yaml
|
||||||
|
|
||||||
# Memory workspace (optional - remove if you want to version control)
|
# Memory workspace — personal data, do NOT commit
|
||||||
memory_workspace/memory/*.md
|
memory_workspace/memory/*.md
|
||||||
memory_workspace/memory_index.db
|
memory_workspace/memory_index.db
|
||||||
memory_workspace/users/*.md # User profiles (jordan.md, etc.)
|
# User profiles (jordan.md, etc.)
|
||||||
|
memory_workspace/users/*.md
|
||||||
memory_workspace/vectors.usearch
|
memory_workspace/vectors.usearch
|
||||||
memory_workspace/obsidian/ # Zettelkasten vault (personal notes)
|
# Zettelkasten vault (personal notes, API keys, credentials)
|
||||||
memory_workspace/SOUL.md # Personal config (use SOUL.example.md)
|
memory_workspace/obsidian/
|
||||||
memory_workspace/MEMORY.md # Personal memory (use MEMORY.example.md)
|
# Personal config (use SOUL.example.md)
|
||||||
|
memory_workspace/SOUL.md
|
||||||
|
# Personal memory
|
||||||
|
memory_workspace/MEMORY.md
|
||||||
|
memory_workspace/MEMORY.md.old
|
||||||
|
|
||||||
# User profiles (personal info)
|
# User profiles (personal info)
|
||||||
users/
|
users/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
207
LOGGING.md
207
LOGGING.md
@@ -1,207 +0,0 @@
|
|||||||
# Structured Logging System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Ajarbot now includes a comprehensive structured logging system to track errors, tool executions, and system behavior.
|
|
||||||
|
|
||||||
## Log Files
|
|
||||||
|
|
||||||
All logs are stored in the `logs/` directory (gitignored):
|
|
||||||
|
|
||||||
### 1. `ajarbot.log` - Main Application Log
|
|
||||||
- **Format**: JSON (one record per line)
|
|
||||||
- **Level**: DEBUG and above
|
|
||||||
- **Size**: Rotates at 10MB, keeps 5 backups
|
|
||||||
- **Contents**: All application events, tool executions, LLM calls
|
|
||||||
|
|
||||||
### 2. `errors.log` - Error-Only Log
|
|
||||||
- **Format**: JSON
|
|
||||||
- **Level**: ERROR and CRITICAL only
|
|
||||||
- **Size**: Rotates at 5MB, keeps 3 backups
|
|
||||||
- **Contents**: Only errors and critical issues for quick diagnosis
|
|
||||||
|
|
||||||
### 3. `tools.log` - Tool Execution Log
|
|
||||||
- **Format**: JSON
|
|
||||||
- **Level**: INFO and above
|
|
||||||
- **Size**: Rotates at 10MB, keeps 3 backups
|
|
||||||
- **Contents**: Every tool call with inputs, outputs, duration, and success/failure
|
|
||||||
|
|
||||||
## Log Format
|
|
||||||
|
|
||||||
### JSON Structure
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-02-16T12:34:56.789Z",
|
|
||||||
"level": "ERROR",
|
|
||||||
"logger": "tools",
|
|
||||||
"message": "Tool failed: permanent_note",
|
|
||||||
"module": "tools",
|
|
||||||
"function": "execute_tool",
|
|
||||||
"line": 500,
|
|
||||||
"extra": {
|
|
||||||
"tool_name": "permanent_note",
|
|
||||||
"inputs": {"title": "Test", "content": "..."},
|
|
||||||
"success": false,
|
|
||||||
"error": "Unknown tool error",
|
|
||||||
"duration_ms": 123.45
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Log Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-02-16T06:00:15.234Z",
|
|
||||||
"level": "INFO",
|
|
||||||
"logger": "tools",
|
|
||||||
"message": "Tool executed: get_weather",
|
|
||||||
"extra": {
|
|
||||||
"tool_name": "get_weather",
|
|
||||||
"inputs": {"location": "Centennial, CO"},
|
|
||||||
"success": true,
|
|
||||||
"result_length": 456,
|
|
||||||
"duration_ms": 1234.56
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage in Code
|
|
||||||
|
|
||||||
### Get a Logger
|
|
||||||
```python
|
|
||||||
from logging_config import get_logger, get_tool_logger
|
|
||||||
|
|
||||||
# General logger
|
|
||||||
logger = get_logger("my_module")
|
|
||||||
|
|
||||||
# Specialized tool logger
|
|
||||||
tool_logger = get_tool_logger()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging Methods
|
|
||||||
|
|
||||||
**Basic logging:**
|
|
||||||
```python
|
|
||||||
logger.debug("Detailed debug info", key="value")
|
|
||||||
logger.info("Informational message", user_id=123)
|
|
||||||
logger.warning("Warning message", issue="something")
|
|
||||||
logger.error("Error occurred", exc_info=True, error_code="E001")
|
|
||||||
logger.critical("Critical system failure", exc_info=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tool execution logging:**
|
|
||||||
```python
|
|
||||||
tool_logger.log_tool_call(
|
|
||||||
tool_name="permanent_note",
|
|
||||||
inputs={"title": "Test", "content": "..."},
|
|
||||||
success=True,
|
|
||||||
result="Created note successfully",
|
|
||||||
duration_ms=123.45
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Analyzing Logs
|
|
||||||
|
|
||||||
### View Recent Errors
|
|
||||||
```bash
|
|
||||||
# Last 20 errors
|
|
||||||
tail -20 logs/errors.log | jq .
|
|
||||||
|
|
||||||
# Errors from specific module
|
|
||||||
grep '"module":"tools"' logs/errors.log | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Performance Analysis
|
|
||||||
```bash
|
|
||||||
# Average tool execution time
|
|
||||||
cat logs/tools.log | jq -r '.extra.duration_ms' | awk '{sum+=$1; count++} END {print sum/count}'
|
|
||||||
|
|
||||||
# Failed tools
|
|
||||||
grep '"success":false' logs/tools.log | jq -r '.extra.tool_name' | sort | uniq -c
|
|
||||||
|
|
||||||
# Slowest tool calls
|
|
||||||
cat logs/tools.log | jq -r '[.extra.tool_name, .extra.duration_ms] | @csv' | sort -t, -k2 -rn | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
### Find Specific Errors
|
|
||||||
```bash
|
|
||||||
# Max token errors
|
|
||||||
grep -i "max.*token" logs/errors.log | jq .
|
|
||||||
|
|
||||||
# Tool iteration limits
|
|
||||||
grep -i "iteration.*exceeded" logs/ajarbot.log | jq .
|
|
||||||
|
|
||||||
# MCP tool failures
|
|
||||||
grep '"tool_name":"permanent_note"' logs/tools.log | grep '"success":false' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Patterns to Watch
|
|
||||||
|
|
||||||
1. **Max Tool Iterations** - Search: `"iteration.*exceeded"`
|
|
||||||
2. **Max Tokens** - Search: `"max.*token"`
|
|
||||||
3. **MCP Tool Failures** - Search: `"Unknown tool"` or failed MCP tool names
|
|
||||||
4. **Slow Tools** - Tools taking > 5000ms
|
|
||||||
5. **Repeated Failures** - Same tool failing multiple times
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
### Log Rotation
|
|
||||||
Logs automatically rotate when they reach size limits:
|
|
||||||
- `ajarbot.log`: 10MB → keeps 5 old files (50MB total)
|
|
||||||
- `errors.log`: 5MB → keeps 3 old files (15MB total)
|
|
||||||
- `tools.log`: 10MB → keeps 3 old files (30MB total)
|
|
||||||
|
|
||||||
Total max disk usage: ~95MB
|
|
||||||
|
|
||||||
### Manual Cleanup
|
|
||||||
```bash
|
|
||||||
# Remove old logs
|
|
||||||
rm logs/*.log.*
|
|
||||||
|
|
||||||
# Clear all logs (careful!)
|
|
||||||
rm logs/*.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration
|
|
||||||
|
|
||||||
### Automatic Integration
|
|
||||||
The logging system is automatically integrated into:
|
|
||||||
- ✅ `tools.py` - All tool executions logged
|
|
||||||
- ✅ Console output - Human-readable format
|
|
||||||
- ✅ File logs - JSON format for parsing
|
|
||||||
|
|
||||||
### Adding Logging to New Modules
|
|
||||||
```python
|
|
||||||
from logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
def my_function():
|
|
||||||
logger.info("Starting operation", operation_id=123)
|
|
||||||
try:
|
|
||||||
# Do work
|
|
||||||
logger.debug("Step completed", step=1)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Operation failed", exc_info=True, operation_id=123)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Quick Error Diagnosis**: Separate `errors.log` for immediate issue identification
|
|
||||||
2. **Performance Tracking**: Tool execution times and success rates
|
|
||||||
3. **Historical Analysis**: JSON format enables programmatic analysis
|
|
||||||
4. **Debugging**: Full context with inputs, outputs, and stack traces
|
|
||||||
5. **Monitoring**: Easy to parse logs for alerting systems
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Web dashboard for log visualization
|
|
||||||
- [ ] Real-time log streaming via WebSocket
|
|
||||||
- [ ] Automatic error rate alerts (email/Telegram)
|
|
||||||
- [ ] Integration with external monitoring (Datadog, CloudWatch)
|
|
||||||
- [ ] Log aggregation for multi-instance deployments
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-16
|
|
||||||
**Log System Version:** 1.0
|
|
||||||
152
MCP_MIGRATION.md
152
MCP_MIGRATION.md
@@ -1,152 +0,0 @@
|
|||||||
# MCP Tools Migration Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Successfully migrated file/system tools to MCP (Model Context Protocol) servers for better performance and integration with Claude Agent SDK.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### MCP Tools (In-Process - No API Costs)
|
|
||||||
**File**: `mcp_tools.py`
|
|
||||||
**Server**: `file_system` (v1.0.0)
|
|
||||||
|
|
||||||
These tools run directly in the Python process using the Claude Agent SDK:
|
|
||||||
- ✅ `read_file` - Read file contents
|
|
||||||
- ✅ `write_file` - Create/overwrite files
|
|
||||||
- ✅ `edit_file` - Replace text in files
|
|
||||||
- ✅ `list_directory` - List directory contents
|
|
||||||
- ✅ `run_command` - Execute shell commands
|
|
||||||
|
|
||||||
**Benefits**:
|
|
||||||
- Zero per-token API costs when using Agent SDK
|
|
||||||
- Better performance (no IPC overhead)
|
|
||||||
- Direct access to application state
|
|
||||||
- Simpler deployment (single process)
|
|
||||||
|
|
||||||
### Traditional Tools (API-Based - Consumes Tokens)
|
|
||||||
**File**: `tools.py`
|
|
||||||
|
|
||||||
These tools require external APIs and fall back to Direct API even in Agent SDK mode:
|
|
||||||
- 🌤️ `get_weather` - OpenWeatherMap API
|
|
||||||
- 📧 `send_email`, `read_emails`, `get_email` - Gmail API
|
|
||||||
- 📅 `read_calendar`, `create_calendar_event`, `search_calendar` - Google Calendar API
|
|
||||||
- 👤 `create_contact`, `list_contacts`, `get_contact` - Google People API
|
|
||||||
|
|
||||||
**Why not MCP?**: These tools need OAuth state, external API calls, and async HTTP clients that are better suited to the traditional tool execution model.
|
|
||||||
|
|
||||||
## Model Configuration
|
|
||||||
|
|
||||||
### Agent SDK Mode (DEFAULT)
|
|
||||||
```python
|
|
||||||
USE_AGENT_SDK=true # Default
|
|
||||||
```
|
|
||||||
|
|
||||||
**Model Configuration**:
|
|
||||||
- Default: **claude-sonnet-4-5-20250929** (all operations - chat, tools, coding)
|
|
||||||
- Optional: **claude-opus-4-6** (requires `USE_OPUS_FOR_TOOLS=true`, only for extremely intensive tasks)
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
- Regular chat: Uses Sonnet (flat-rate, no API costs)
|
|
||||||
- File operations: Uses Sonnet via MCP tools (flat-rate, no API costs)
|
|
||||||
- Google/Weather: Uses Sonnet via Direct API fallback (requires ANTHROPIC_API_KEY, consumes tokens)
|
|
||||||
- Intensive tasks: Optionally enable Opus with `USE_OPUS_FOR_TOOLS=true` (flat-rate, no extra cost)
|
|
||||||
|
|
||||||
**Cost Structure**:
|
|
||||||
- Chat + MCP tools: Flat-rate subscription (Pro plan)
|
|
||||||
- Traditional tools (Google/Weather): Pay-per-token at Sonnet rates (requires API key)
|
|
||||||
|
|
||||||
### Direct API Mode
|
|
||||||
```python
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
Model: claude-sonnet-4-5-20250929 # Cost-effective (never uses Opus - too expensive)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
- All operations: Pay-per-token
|
|
||||||
- Requires: ANTHROPIC_API_KEY in .env
|
|
||||||
- All tools: Traditional execution (same token cost)
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### MCP Server Integration
|
|
||||||
|
|
||||||
**In `llm_interface.py`**:
|
|
||||||
```python
|
|
||||||
from mcp_tools import file_system_server
|
|
||||||
|
|
||||||
options = ClaudeAgentOptions(
|
|
||||||
mcp_servers={"file_system": file_system_server},
|
|
||||||
allowed_tools=[
|
|
||||||
"read_file", "write_file", "edit_file",
|
|
||||||
"list_directory", "run_command"
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await query(
|
|
||||||
messages=sdk_messages,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool Definition Format
|
|
||||||
|
|
||||||
**MCP Tool Example**:
|
|
||||||
```python
|
|
||||||
@tool(
|
|
||||||
name="read_file",
|
|
||||||
description="Read the contents of a file.",
|
|
||||||
input_schema={"file_path": str},
|
|
||||||
)
|
|
||||||
async def read_file_tool(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": "..."}],
|
|
||||||
"isError": False # Optional
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Traditional Tool Example**:
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"name": "send_email",
|
|
||||||
"description": "Send an email from the bot's Gmail account.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"to": {"type": "string"}, ...},
|
|
||||||
"required": ["to", "subject", "body"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Potential MCP Candidates
|
|
||||||
- [ ] Weather tool (if we cache API responses in-process)
|
|
||||||
- [ ] Memory search tools (direct DB access)
|
|
||||||
- [ ] Configuration management tools
|
|
||||||
|
|
||||||
### Google Tools Migration (Optional)
|
|
||||||
To fully migrate Google tools to MCP, we would need to:
|
|
||||||
1. Embed OAuth manager in MCP server lifecycle
|
|
||||||
2. Handle async HTTP clients within MCP context
|
|
||||||
3. Manage token refresh in-process
|
|
||||||
|
|
||||||
**Recommendation**: Keep Google tools as traditional tools for now. The complexity of OAuth state management outweighs the token cost savings for infrequent API calls.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test MCP server creation
|
|
||||||
python -c "from mcp_tools import file_system_server; print(file_system_server)"
|
|
||||||
|
|
||||||
# Test Agent SDK with Opus
|
|
||||||
python -c "import os; os.environ['USE_AGENT_SDK']='true'; from llm_interface import LLMInterface; llm = LLMInterface(provider='claude'); print(f'Model: {llm.model}')"
|
|
||||||
|
|
||||||
# Expected: Model: claude-opus-4-6
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Claude Agent SDK Docs: https://github.com/anthropics/claude-agent-sdk
|
|
||||||
- MCP Protocol: https://modelcontextprotocol.io
|
|
||||||
- Tool Decorators: `claude_agent_sdk.tool`, `create_sdk_mcp_server`
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# Agent SDK Quick Reference Card
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Run bot (Agent SDK is default)
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Mode Selection
|
|
||||||
|
|
||||||
### Agent SDK (Default)
|
|
||||||
```env
|
|
||||||
# No config needed - this is the default!
|
|
||||||
# Or explicitly:
|
|
||||||
USE_AGENT_SDK=true
|
|
||||||
```
|
|
||||||
✅ Uses Claude Pro subscription (no API costs)
|
|
||||||
|
|
||||||
### Direct API
|
|
||||||
```env
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
✅ Pay-per-token, usage tracking enabled
|
|
||||||
|
|
||||||
### Legacy Server
|
|
||||||
```env
|
|
||||||
USE_CLAUDE_CODE_SERVER=true
|
|
||||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
⚠️ Deprecated, not recommended
|
|
||||||
|
|
||||||
## 🔍 Verify Mode
|
|
||||||
|
|
||||||
Check startup message:
|
|
||||||
```
|
|
||||||
[LLM] Using Claude Agent SDK (Pro subscription) ← Agent SDK ✅
|
|
||||||
[LLM] Using Direct API (pay-per-token) ← Direct API 💳
|
|
||||||
[LLM] Using Claude Code server at ... ← Legacy ⚠️
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Test Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python test_agent_sdk.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: **5/5 tests passed** 🎉
|
|
||||||
|
|
||||||
## 🛠️ Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Fallback to Direct API
|
|
||||||
```bash
|
|
||||||
pip install claude-agent-sdk anyio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: ModuleNotFoundError
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Still using old mode
|
|
||||||
```bash
|
|
||||||
# Edit .env and remove conflicting variables
|
|
||||||
USE_DIRECT_API=false # or remove line
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Priority Order
|
|
||||||
|
|
||||||
```
|
|
||||||
1. USE_DIRECT_API=true → Direct API
|
|
||||||
2. USE_CLAUDE_CODE_SERVER → Legacy
|
|
||||||
3. USE_AGENT_SDK (default) → Agent SDK
|
|
||||||
4. SDK unavailable → Fallback to Direct API
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💰 Cost Comparison
|
|
||||||
|
|
||||||
| Mode | Cost per 1M tokens |
|
|
||||||
|------|-------------------|
|
|
||||||
| Agent SDK | **$0** (Pro subscription) |
|
|
||||||
| Direct API (Haiku) | $0.25 - $1.25 |
|
|
||||||
| Direct API (Sonnet) | $3.00 - $15.00 |
|
|
||||||
|
|
||||||
## 🎯 Key Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `llm_interface.py` | Core implementation |
|
|
||||||
| `requirements.txt` | Dependencies |
|
|
||||||
| `test_agent_sdk.py` | Test suite |
|
|
||||||
| `.env` | Configuration |
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
- `AGENT_SDK_IMPLEMENTATION.md` - Full technical details
|
|
||||||
- `MIGRATION_GUIDE_AGENT_SDK.md` - Step-by-step migration
|
|
||||||
- `IMPLEMENTATION_SUMMARY.md` - Executive summary
|
|
||||||
- `QUICK_REFERENCE_AGENT_SDK.md` - This file
|
|
||||||
|
|
||||||
## ✅ Features Preserved
|
|
||||||
|
|
||||||
✅ All 17 tools (file ops, Gmail, Calendar)
|
|
||||||
✅ Scheduled tasks
|
|
||||||
✅ Memory system
|
|
||||||
✅ Self-healing system
|
|
||||||
✅ Telegram adapter
|
|
||||||
✅ Slack adapter
|
|
||||||
✅ Model switching (/sonnet, /haiku)
|
|
||||||
✅ Usage tracking (Direct API mode)
|
|
||||||
|
|
||||||
## 🔄 Rollback
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Quick rollback to Direct API
|
|
||||||
USE_DIRECT_API=true
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart bot. Done! ✅
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
1. Check logs: Look for `[LLM]` messages
|
|
||||||
2. Run tests: `python test_agent_sdk.py`
|
|
||||||
3. Check mode: Verify startup message
|
|
||||||
4. Review docs: See files above
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Date**: 2026-02-15
|
|
||||||
**Status**: ✅ Production Ready
|
|
||||||
59
SETUP.md
59
SETUP.md
@@ -1,59 +0,0 @@
|
|||||||
# Ajarbot Setup Guide
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
|
||||||
cd ajarbot
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set up Python environment**
|
|
||||||
```bash
|
|
||||||
python -m venv venv
|
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure credentials**
|
|
||||||
```bash
|
|
||||||
# Copy example files
|
|
||||||
copy .env.example .env
|
|
||||||
copy config\scheduled_tasks.example.yaml config\scheduled_tasks.yaml
|
|
||||||
copy config\adapters.yaml config\adapters.local.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add your API keys**
|
|
||||||
- Edit `.env` and add your `ANTHROPIC_API_KEY`
|
|
||||||
- Edit `config\adapters.local.yaml` with your Slack/Telegram tokens
|
|
||||||
- Edit `config\scheduled_tasks.yaml` with your user/channel IDs
|
|
||||||
|
|
||||||
5. **Run the bot**
|
|
||||||
```bash
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Files (NOT in Git)
|
|
||||||
|
|
||||||
These files contain your secrets and are ignored by git:
|
|
||||||
- `.env` - Your API keys
|
|
||||||
- `config/adapters.local.yaml` - Your bot tokens
|
|
||||||
- `config/scheduled_tasks.yaml` - Your user IDs
|
|
||||||
- `memory_workspace/memory_index.db` - Your conversation history
|
|
||||||
- `memory_workspace/memory/*.md` - Your daily logs
|
|
||||||
|
|
||||||
## Model Switching Commands
|
|
||||||
|
|
||||||
Send these commands to your bot:
|
|
||||||
- `/haiku` - Switch to Haiku (cheap, fast)
|
|
||||||
- `/sonnet` - Switch to Sonnet (smart, caching enabled)
|
|
||||||
- `/status` - Check current model and settings
|
|
||||||
|
|
||||||
## Cost Optimization
|
|
||||||
|
|
||||||
- Default model: Haiku 4.5 (12x cheaper than Sonnet)
|
|
||||||
- Prompt caching: Automatic when using Sonnet (90% savings)
|
|
||||||
- Context optimized: 3 messages, 2 memory results
|
|
||||||
- Max tool iterations: 5
|
|
||||||
|
|
||||||
See [README.md](README.md) for full documentation.
|
|
||||||
205
SUB_AGENTS.md
205
SUB_AGENTS.md
@@ -1,205 +0,0 @@
|
|||||||
# Sub-Agent Orchestration System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Ajarbot now supports **dynamic sub-agent spawning** - the ability to create specialized agents on-demand for complex tasks. The main agent can delegate work to specialists with focused system prompts, reducing context window bloat and improving task efficiency.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Main Agent (Garvis)
|
|
||||||
├─> Handles general chat, memory, scheduling
|
|
||||||
├─> Can spawn sub-agents dynamically
|
|
||||||
└─> Sub-agents share tools and (optionally) memory
|
|
||||||
|
|
||||||
Sub-Agent (Specialist)
|
|
||||||
├─> Focused system prompt (no SOUL, user profile overhead)
|
|
||||||
├─> Own conversation history (isolated context)
|
|
||||||
├─> Can use all 24 tools
|
|
||||||
└─> Returns result to main agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- **Dynamic spawning**: Create specialists at runtime, no hardcoded definitions
|
|
||||||
- **Caching**: Reuse specialists across multiple calls (agent_id parameter)
|
|
||||||
- **Memory sharing**: Sub-agents can share memory workspace with main agent
|
|
||||||
- **Tool access**: All tools available to sub-agents (file, web, zettelkasten, Google)
|
|
||||||
- **Isolation**: Each sub-agent has separate conversation history
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Method 1: Manual Spawning
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Spawn a specialist
|
|
||||||
specialist = agent.spawn_sub_agent(
|
|
||||||
specialist_prompt="You are a zettelkasten expert. Focus ONLY on note organization.",
|
|
||||||
agent_id="zettelkasten_processor" # Optional: cache for reuse
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the specialist
|
|
||||||
result = specialist.chat("Process my fleeting notes", username="jordan")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: Delegation (Recommended)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# One-off delegation (specialist not cached)
|
|
||||||
result = agent.delegate(
|
|
||||||
task="Analyze my emails and extract action items",
|
|
||||||
specialist_prompt="You are an email analyst. Extract action items and deadlines.",
|
|
||||||
username="jordan"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cached delegation (specialist reused)
|
|
||||||
result = agent.delegate(
|
|
||||||
task="Create permanent notes from my fleeting notes",
|
|
||||||
specialist_prompt="You are a zettelkasten specialist. Focus on note linking.",
|
|
||||||
username="jordan",
|
|
||||||
agent_id="zettelkasten_processor" # Cached for future use
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 3: LLM-Driven Orchestration (Future)
|
|
||||||
|
|
||||||
The main agent can analyze requests and decide when to delegate:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _should_delegate(self, user_message: str) -> Optional[str]:
|
|
||||||
"""Let LLM decide if delegation is needed."""
|
|
||||||
# Ask LLM: "Should this be delegated? If yes, generate specialist prompt"
|
|
||||||
# Return specialist_prompt if delegation needed, None otherwise
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
### Complex Zettelkasten Operations
|
|
||||||
```python
|
|
||||||
# Main agent detects: "This requires deep note processing"
|
|
||||||
specialist = agent.spawn_sub_agent(
|
|
||||||
specialist_prompt="""You are a zettelkasten expert. Your ONLY job is:
|
|
||||||
- Process fleeting notes into permanent notes
|
|
||||||
- Find semantic connections using hybrid search
|
|
||||||
- Create wiki-style links between related concepts
|
|
||||||
Stay focused on knowledge management.""",
|
|
||||||
agent_id="zettelkasten_processor"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Email Intelligence
|
|
||||||
```python
|
|
||||||
specialist = agent.spawn_sub_agent(
|
|
||||||
specialist_prompt="""You are an email analyst. Your ONLY job is:
|
|
||||||
- Summarize email threads
|
|
||||||
- Extract action items and deadlines
|
|
||||||
- Identify patterns in communication
|
|
||||||
Stay focused on email analysis.""",
|
|
||||||
agent_id="email_analyst"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Calendar Optimization
|
|
||||||
```python
|
|
||||||
specialist = agent.spawn_sub_agent(
|
|
||||||
specialist_prompt="""You are a calendar optimization expert. Your ONLY job is:
|
|
||||||
- Find scheduling conflicts
|
|
||||||
- Suggest optimal meeting times
|
|
||||||
- Identify time-blocking opportunities
|
|
||||||
Stay focused on schedule management.""",
|
|
||||||
agent_id="calendar_optimizer"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Reduced Context Window**: Specialists don't load SOUL.md, user profiles, or irrelevant memory
|
|
||||||
2. **Focused Performance**: Specialists stay on-task without distractions
|
|
||||||
3. **Token Efficiency**: Smaller system prompts = lower token usage
|
|
||||||
4. **Parallel Execution**: Can spawn multiple specialists simultaneously (future)
|
|
||||||
5. **Learning Over Time**: Main agent learns when to delegate based on patterns
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
No configuration needed! The infrastructure is ready to use. You can:
|
|
||||||
|
|
||||||
1. **Add specialists later**: Define common specialists in a config file
|
|
||||||
2. **LLM-driven delegation**: Let the main agent decide when to delegate
|
|
||||||
3. **Parallel execution**: Spawn multiple specialists for complex workflows
|
|
||||||
4. **Custom workspaces**: Give specialists isolated memory (set `share_memory=False`)
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Code Location
|
|
||||||
- **agent.py**: Lines 25-90 (sub-agent infrastructure)
|
|
||||||
- `spawn_sub_agent()`: Create specialist with custom prompt
|
|
||||||
- `delegate()`: Convenience method for one-off delegation
|
|
||||||
- `is_sub_agent`, `specialist_prompt`: Instance variables
|
|
||||||
- `sub_agents`: Cache dictionary
|
|
||||||
|
|
||||||
### Thread Safety
|
|
||||||
- Sub-agents have their own `_chat_lock`
|
|
||||||
- Safe to spawn from multiple threads
|
|
||||||
- Cached specialists are reused (no duplicate spawning)
|
|
||||||
|
|
||||||
### Memory Sharing
|
|
||||||
- Default: Sub-agents share main memory workspace
|
|
||||||
- Optional: Isolated workspace at `memory_workspace/sub_agents/{agent_id}/`
|
|
||||||
- Shared memory = specialists can access/update zettelkasten vault
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Specialist Registry**: Define common specialists in `config/specialists.yaml`
|
|
||||||
2. **Auto-Delegation**: Main agent auto-detects when to delegate
|
|
||||||
3. **Parallel Execution**: Run multiple specialists concurrently
|
|
||||||
4. **Result Synthesis**: Main agent combines outputs from multiple specialists
|
|
||||||
5. **Learning System**: Track which specialists work best for which tasks
|
|
||||||
|
|
||||||
## Example Workflows
|
|
||||||
|
|
||||||
### Workflow 1: Zettelkasten Processing with Delegation
|
|
||||||
```python
|
|
||||||
# User: "Process my fleeting notes about AI and machine learning"
|
|
||||||
# Main agent detects: complex zettelkasten task
|
|
||||||
|
|
||||||
result = agent.delegate(
|
|
||||||
task="Find all fleeting notes tagged 'AI' or 'machine-learning', process into permanent notes, and discover connections",
|
|
||||||
specialist_prompt="You are a zettelkasten expert. Use hybrid search to find semantic connections. Create permanent notes with smart links.",
|
|
||||||
username="jordan",
|
|
||||||
agent_id="zettelkasten_processor"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Specialist:
|
|
||||||
# 1. search_by_tags(tags=["AI", "machine-learning", "fleeting"])
|
|
||||||
# 2. For each note: permanent_note() with auto-linking
|
|
||||||
# 3. Returns: "Created 5 permanent notes with 18 discovered connections"
|
|
||||||
|
|
||||||
# Main agent synthesizes:
|
|
||||||
# "Sir, I've processed your AI and ML notes. Five concepts emerged with particularly
|
|
||||||
# interesting connections to your existing work on neural architecture..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow 2: Email + Calendar Coordination
|
|
||||||
```python
|
|
||||||
# User: "Find meetings next week and check if I have email threads about them"
|
|
||||||
|
|
||||||
# Spawn two specialists in parallel (future feature)
|
|
||||||
email_result = agent.delegate(
|
|
||||||
task="Search emails for threads about meetings",
|
|
||||||
specialist_prompt="Email analyst. Extract meeting context.",
|
|
||||||
agent_id="email_analyst"
|
|
||||||
)
|
|
||||||
|
|
||||||
calendar_result = agent.delegate(
|
|
||||||
task="List all meetings next week",
|
|
||||||
specialist_prompt="Calendar expert. Get meeting details.",
|
|
||||||
agent_id="calendar_optimizer"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Main agent synthesizes both results
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Infrastructure complete, ready to use. Add specialists as patterns emerge!
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# Windows 11 Quick Reference
|
|
||||||
|
|
||||||
Quick command reference for testing and running Ajarbot on Windows 11.
|
|
||||||
|
|
||||||
## First Time Setup (5 Minutes)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Step 1: Navigate to project
|
|
||||||
cd c:\Users\fam1n\projects\ajarbot
|
|
||||||
|
|
||||||
# Step 2: Run automated setup
|
|
||||||
quick_start.bat
|
|
||||||
|
|
||||||
# Step 3: Set API key (if prompted)
|
|
||||||
# Get your key from: https://console.anthropic.com/
|
|
||||||
|
|
||||||
# Step 4: Verify installation
|
|
||||||
python test_installation.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Examples (Choose One)
|
|
||||||
|
|
||||||
### Option 1: Basic Agent Test
|
|
||||||
```powershell
|
|
||||||
python example_usage.py
|
|
||||||
```
|
|
||||||
**What it does:** Tests basic chat and memory
|
|
||||||
|
|
||||||
### Option 2: Pulse & Brain Monitoring
|
|
||||||
```powershell
|
|
||||||
python example_bot_with_pulse_brain.py
|
|
||||||
```
|
|
||||||
**What it does:** Runs cost-effective monitoring
|
|
||||||
**To stop:** Press `Ctrl+C`
|
|
||||||
|
|
||||||
### Option 3: Task Scheduler
|
|
||||||
```powershell
|
|
||||||
python example_bot_with_scheduler.py
|
|
||||||
```
|
|
||||||
**What it does:** Shows scheduled task execution
|
|
||||||
**To stop:** Press `Ctrl+C`
|
|
||||||
|
|
||||||
### Option 4: Multi-Platform Bot
|
|
||||||
```powershell
|
|
||||||
# Generate config file
|
|
||||||
python bot_runner.py --init
|
|
||||||
|
|
||||||
# Edit config (add Slack/Telegram tokens)
|
|
||||||
notepad config\adapters.local.yaml
|
|
||||||
|
|
||||||
# Run bot
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
**To stop:** Press `Ctrl+C`
|
|
||||||
|
|
||||||
## Daily Commands
|
|
||||||
|
|
||||||
### Activate Virtual Environment
|
|
||||||
```powershell
|
|
||||||
cd c:\Users\fam1n\projects\ajarbot
|
|
||||||
.\venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Start Bot
|
|
||||||
```powershell
|
|
||||||
python bot_runner.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Health
|
|
||||||
```powershell
|
|
||||||
python bot_runner.py --health
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Logs
|
|
||||||
```powershell
|
|
||||||
type logs\bot.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Dependencies
|
|
||||||
```powershell
|
|
||||||
pip install -r requirements.txt --upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Key Management
|
|
||||||
|
|
||||||
### Set for Current Session
|
|
||||||
```powershell
|
|
||||||
$env:ANTHROPIC_API_KEY = "sk-ant-your-key-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set Permanently (System)
|
|
||||||
1. Press `Win + X`
|
|
||||||
2. Click "System"
|
|
||||||
3. Click "Advanced system settings"
|
|
||||||
4. Click "Environment Variables"
|
|
||||||
5. Under "User variables", click "New"
|
|
||||||
6. Variable: `ANTHROPIC_API_KEY`
|
|
||||||
7. Value: `sk-ant-your-key-here`
|
|
||||||
|
|
||||||
### Check if Set
|
|
||||||
```powershell
|
|
||||||
$env:ANTHROPIC_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running as Service
|
|
||||||
|
|
||||||
### Quick Background Run
|
|
||||||
```powershell
|
|
||||||
Start-Process python -ArgumentList "bot_runner.py" -WindowStyle Hidden
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop Background Process
|
|
||||||
```powershell
|
|
||||||
# Find process
|
|
||||||
Get-Process python | Where-Object {$_.CommandLine -like "*bot_runner*"}
|
|
||||||
|
|
||||||
# Stop it (replace <PID> with actual process ID)
|
|
||||||
Stop-Process -Id <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Python not recognized"
|
|
||||||
```powershell
|
|
||||||
# Add to PATH
|
|
||||||
# Win + X -> System -> Advanced -> Environment Variables
|
|
||||||
# Edit PATH, add: C:\Users\fam1n\AppData\Local\Programs\Python\Python3XX
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Module not found"
|
|
||||||
```powershell
|
|
||||||
.\venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt --force-reinstall
|
|
||||||
```
|
|
||||||
|
|
||||||
### "API key not found"
|
|
||||||
```powershell
|
|
||||||
$env:ANTHROPIC_API_KEY = "sk-ant-your-key-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reset Memory
|
|
||||||
```powershell
|
|
||||||
Remove-Item -Recurse -Force memory_workspace
|
|
||||||
python example_usage.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Files Quick Reference
|
|
||||||
|
|
||||||
| File/Folder | Purpose |
|
|
||||||
|-------------|---------|
|
|
||||||
| `agent.py` | Main agent logic |
|
|
||||||
| `bot_runner.py` | Multi-platform bot launcher |
|
|
||||||
| `pulse_brain.py` | Monitoring system |
|
|
||||||
| `example_*.py` | Example scripts to test |
|
|
||||||
| `test_*.py` | Test scripts |
|
|
||||||
| `config/` | Configuration files |
|
|
||||||
| `docs/` | Full documentation |
|
|
||||||
| `adapters/` | Platform integrations |
|
|
||||||
| `memory_workspace/` | Memory database |
|
|
||||||
|
|
||||||
## Model Switching
|
|
||||||
|
|
||||||
Tell your bot via chat:
|
|
||||||
- `/haiku` - Fast, cheap (default)
|
|
||||||
- `/sonnet` - Smart, caching enabled
|
|
||||||
- `/status` - Check current model
|
|
||||||
|
|
||||||
## Need More Help?
|
|
||||||
|
|
||||||
- **Complete Setup Guide:** [SETUP.md](SETUP.md)
|
|
||||||
- **Full Windows Guide:** [docs/WINDOWS_DEPLOYMENT.md](docs/WINDOWS_DEPLOYMENT.md)
|
|
||||||
- **Main Documentation:** [docs/README.md](docs/README.md)
|
|
||||||
@@ -5,7 +5,6 @@ Connects messaging platform adapters to the Agent instance.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
@@ -281,39 +280,3 @@ class AdapterRuntime:
|
|||||||
status["adapters"][adapter.platform_name] = adapter_health
|
status["adapters"][adapter.platform_name] = adapter_health
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
# --- Example Preprocessors and Postprocessors ---
|
|
||||||
|
|
||||||
|
|
||||||
def command_preprocessor(message: InboundMessage) -> InboundMessage:
|
|
||||||
"""Example: Handle bot commands."""
|
|
||||||
if not message.text.startswith("/"):
|
|
||||||
return message
|
|
||||||
|
|
||||||
parts = message.text.split(maxsplit=1)
|
|
||||||
command = parts[0]
|
|
||||||
|
|
||||||
if command == "/status":
|
|
||||||
message.text = "What is your current status?"
|
|
||||||
elif command == "/help":
|
|
||||||
message.text = (
|
|
||||||
"Please provide help information about what you can do."
|
|
||||||
)
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
def markdown_postprocessor(
|
|
||||||
response: str, original_message: InboundMessage
|
|
||||||
) -> str:
|
|
||||||
"""Example: Ensure markdown compatibility for Slack."""
|
|
||||||
if original_message.platform != "slack":
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Convert standard markdown bold to Slack mrkdwn
|
|
||||||
response = response.replace("**", "*")
|
|
||||||
# Slack doesn't support ## headers
|
|
||||||
response = re.sub(r"^#+\s+", "", response, flags=re.MULTILINE)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Allows the Agent to invoke local skills programmatically,
|
|||||||
enabling advanced automation and dynamic behavior.
|
enabling advanced automation and dynamic behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
@@ -83,46 +82,6 @@ class SkillInvoker:
|
|||||||
info["path"] = str(skill_path)
|
info["path"] = str(skill_path)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
def invoke_skill_via_cli(
|
|
||||||
self, skill_name: str, *args: str
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Invoke a skill via Claude Code CLI.
|
|
||||||
|
|
||||||
Requires claude-code CLI to be installed and in PATH.
|
|
||||||
For production, integrate with the Agent's LLM directly.
|
|
||||||
"""
|
|
||||||
# Validate skill_name
|
|
||||||
if not skill_name or not skill_name.replace("-", "").replace("_", "").isalnum():
|
|
||||||
raise ValueError(
|
|
||||||
"Invalid skill name: must contain only alphanumeric, "
|
|
||||||
"hyphens, and underscores"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate arguments don't contain shell metacharacters
|
|
||||||
for arg in args:
|
|
||||||
if any(char in str(arg) for char in ['&', '|', ';', '$', '`', '\n', '\r']):
|
|
||||||
raise ValueError(
|
|
||||||
"Invalid argument: contains shell metacharacters"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cmd = ["claude-code", f"/{skill_name}"] + list(args)
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=self.project_root,
|
|
||||||
timeout=60, # Add timeout to prevent hanging
|
|
||||||
)
|
|
||||||
return result.stdout if result.returncode == 0 else None
|
|
||||||
except FileNotFoundError:
|
|
||||||
print("[SkillInvoker] claude-code CLI not found")
|
|
||||||
return None
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print(f"[SkillInvoker] Skill {skill_name} timed out")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def invoke_skill_via_agent(
|
def invoke_skill_via_agent(
|
||||||
self, skill_name: str, agent: Any, *args: str
|
self, skill_name: str, agent: Any, *args: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -193,20 +152,3 @@ def skill_based_preprocessor(
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
return preprocessor
|
return preprocessor
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
invoker = SkillInvoker()
|
|
||||||
|
|
||||||
print("Available skills:")
|
|
||||||
for skill in invoker.list_available_skills():
|
|
||||||
info = invoker.get_skill_info(skill)
|
|
||||||
print(f" /{skill}")
|
|
||||||
if info:
|
|
||||||
print(
|
|
||||||
f" Description: {info.get('description', 'N/A')}"
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f" User-invocable: "
|
|
||||||
f"{info.get('user-invocable', 'N/A')}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ class SlackAdapter(BaseAdapter):
|
|||||||
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
||||||
|
|
||||||
print("[Slack] Starting Socket Mode connection...")
|
print("[Slack] Starting Socket Mode connection...")
|
||||||
await self.handler.start_async()
|
# Connect to Slack (non-blocking)
|
||||||
|
await self.handler.connect_async()
|
||||||
|
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
print("[Slack] Connected and listening for messages")
|
print("[Slack] Connected and listening for messages")
|
||||||
@@ -97,7 +98,7 @@ class SlackAdapter(BaseAdapter):
|
|||||||
"""Stop the Slack Socket Mode connection."""
|
"""Stop the Slack Socket Mode connection."""
|
||||||
if self.handler:
|
if self.handler:
|
||||||
print("[Slack] Stopping Socket Mode connection...")
|
print("[Slack] Stopping Socket Mode connection...")
|
||||||
await self.handler.close_async()
|
await self.handler.disconnect_async()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
print("[Slack] Disconnected")
|
print("[Slack] Disconnected")
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Telegram adapter for ajarbot.
|
|||||||
Uses python-telegram-bot library for async Telegram Bot API integration.
|
Uses python-telegram-bot library for async Telegram Bot API integration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from telegram import Bot, Update
|
from telegram import Bot, Update
|
||||||
@@ -42,6 +43,7 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.application: Optional[Application] = None
|
self.application: Optional[Application] = None
|
||||||
self.bot: Optional[Bot] = None
|
self.bot: Optional[Bot] = None
|
||||||
|
self._polling_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def platform_name(self) -> str:
|
def platform_name(self) -> str:
|
||||||
@@ -86,9 +88,13 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
print("[Telegram] Starting bot...")
|
print("[Telegram] Starting bot...")
|
||||||
await self.application.initialize()
|
await self.application.initialize()
|
||||||
await self.application.start()
|
await self.application.start()
|
||||||
await self.application.updater.start_polling(
|
|
||||||
allowed_updates=Update.ALL_TYPES,
|
# Run polling in a background task instead of blocking
|
||||||
drop_pending_updates=True,
|
self._polling_task = asyncio.create_task(
|
||||||
|
self.application.updater.start_polling(
|
||||||
|
allowed_updates=Update.ALL_TYPES,
|
||||||
|
drop_pending_updates=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
@@ -106,7 +112,15 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
await self.application.stop()
|
await self.application.stop()
|
||||||
await self.application.shutdown()
|
await self.application.shutdown()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
print("[Telegram] Bot stopped")
|
|
||||||
|
if self._polling_task and not self._polling_task.done():
|
||||||
|
self._polling_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._polling_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("[Telegram] Bot stopped")
|
||||||
|
|
||||||
def _register_handlers(self) -> None:
|
def _register_handlers(self) -> None:
|
||||||
"""Register Telegram message handlers."""
|
"""Register Telegram message handlers."""
|
||||||
|
|||||||
3
agent.py
3
agent.py
@@ -1,7 +1,6 @@
|
|||||||
"""AI Agent with Memory and LLM Integration."""
|
"""AI Agent with Memory and LLM Integration."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from typing import List, Optional, Callable
|
from typing import List, Optional, Callable
|
||||||
|
|
||||||
from hooks import HooksSystem
|
from hooks import HooksSystem
|
||||||
@@ -12,8 +11,6 @@ from tools import TOOL_DEFINITIONS, execute_tool
|
|||||||
|
|
||||||
# Maximum number of recent messages to include in LLM context
|
# Maximum number of recent messages to include in LLM context
|
||||||
MAX_CONTEXT_MESSAGES = 20 # Optimized for Agent SDK flat-rate subscription
|
MAX_CONTEXT_MESSAGES = 20 # Optimized for Agent SDK flat-rate subscription
|
||||||
# Maximum characters of agent response to store in memory
|
|
||||||
MEMORY_RESPONSE_PREVIEW_LENGTH = 500 # Store more context for better memory retrieval
|
|
||||||
# Maximum conversation history entries before pruning
|
# Maximum conversation history entries before pruning
|
||||||
MAX_CONVERSATION_HISTORY = 100 # Higher limit with flat-rate subscription
|
MAX_CONVERSATION_HISTORY = 100 # Higher limit with flat-rate subscription
|
||||||
# Maximum tool execution iterations (generous limit for complex operations like zettelkasten)
|
# Maximum tool execution iterations (generous limit for complex operations like zettelkasten)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ Environment variables:
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
import sys
|
|
||||||
import traceback
|
import traceback
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -119,8 +118,8 @@ class BotRunner:
|
|||||||
print(f"[Setup] {len(enabled_tasks)} scheduled task(s) enabled:")
|
print(f"[Setup] {len(enabled_tasks)} scheduled task(s) enabled:")
|
||||||
for task_info in enabled_tasks:
|
for task_info in enabled_tasks:
|
||||||
print(f" - {task_info['name']}: {task_info['schedule']}")
|
print(f" - {task_info['name']}: {task_info['schedule']}")
|
||||||
if task_info.get("send_to_platform"):
|
if task_info.get("send_to"):
|
||||||
print(f" → {task_info['send_to_platform']}")
|
print(f" → {task_info['send_to']}")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
# Security Audit Summary
|
|
||||||
|
|
||||||
**Date:** 2026-02-12
|
|
||||||
**Auditors:** 5 Opus 4.6 Agents (Parallel Execution)
|
|
||||||
**Status:** ✅ Critical vulnerabilities fixed
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
A comprehensive security audit was performed on the entire ajarbot codebase using 5 specialized Opus 4.6 agents running in parallel. The audit identified **32 security findings** across 4 severity levels:
|
|
||||||
|
|
||||||
- **Critical:** 3 findings (ALL FIXED)
|
|
||||||
- **High:** 9 findings (ALL FIXED)
|
|
||||||
- **Medium:** 14 findings (6 FIXED, 8 remaining non-critical)
|
|
||||||
- **Low:** 6 findings (informational)
|
|
||||||
|
|
||||||
All critical and high-severity vulnerabilities have been remediated. The codebase is now safe for testing and deployment.
|
|
||||||
|
|
||||||
## Critical Vulnerabilities Fixed
|
|
||||||
|
|
||||||
### 1. Path Traversal in Memory System (CRITICAL → FIXED)
|
|
||||||
**Files:** `memory_system.py` (read_file, update_user, get_user)
|
|
||||||
**Risk:** Arbitrary file read/write anywhere on the filesystem
|
|
||||||
**Fix Applied:**
|
|
||||||
- Added validation that username contains only alphanumeric, hyphens, and underscores
|
|
||||||
- Added path resolution checks using `.resolve()` and `.is_relative_to()`
|
|
||||||
- Prevents traversal attacks like `../../etc/passwd` or `../../.env`
|
|
||||||
|
|
||||||
### 2. Format String Injection in Pulse Brain (CRITICAL → FIXED)
|
|
||||||
**File:** `pulse_brain.py:410`
|
|
||||||
**Risk:** Information disclosure, potential code execution via object attribute access
|
|
||||||
**Fix Applied:**
|
|
||||||
- Replaced `.format(**data)` with `string.Template.safe_substitute()`
|
|
||||||
- All data values converted to strings before substitution
|
|
||||||
- Updated all template strings in `config/pulse_brain_config.py` to use `$variable` syntax
|
|
||||||
|
|
||||||
### 3. Command & Prompt Injection in Skills (CRITICAL → FIXED)
|
|
||||||
**File:** `adapters/skill_integration.py`
|
|
||||||
**Risk:** Arbitrary command execution and prompt injection
|
|
||||||
**Fixes Applied:**
|
|
||||||
- Added skill_name validation (alphanumeric, hyphens, underscores only)
|
|
||||||
- Added argument validation to reject shell metacharacters
|
|
||||||
- Added 60-second timeout to subprocess calls
|
|
||||||
- Wrapped user arguments in `<user_input>` XML tags to prevent prompt injection
|
|
||||||
- Limited argument length to 1000 characters
|
|
||||||
- Changed from privileged "skill-invoker" username to "default"
|
|
||||||
|
|
||||||
## High-Severity Vulnerabilities Fixed
|
|
||||||
|
|
||||||
### 4. FTS5 Query Injection (HIGH → FIXED)
|
|
||||||
**File:** `memory_system.py` (search, search_user methods)
|
|
||||||
**Risk:** Enumerate all memory content via FTS5 query syntax
|
|
||||||
**Fix Applied:**
|
|
||||||
- Created `_sanitize_fts5_query()` static method
|
|
||||||
- Wraps queries in double quotes to treat as phrase search
|
|
||||||
- Escapes double quotes within query strings
|
|
||||||
|
|
||||||
### 5. Credential Exposure in Config Dump (HIGH → FIXED)
|
|
||||||
**File:** `config/config_loader.py:143`
|
|
||||||
**Risk:** API keys and tokens printed to stdout/logs
|
|
||||||
**Fix Applied:**
|
|
||||||
- Added `redact_credentials()` function
|
|
||||||
- Masks credentials showing only first 4 and last 4 characters
|
|
||||||
- Applied to config dump in `__main__` block
|
|
||||||
|
|
||||||
### 6. Thread Safety in Pulse Brain (HIGH → FIXED)
|
|
||||||
**File:** `pulse_brain.py`
|
|
||||||
**Risk:** Race conditions, data corruption, inconsistent state
|
|
||||||
**Fix Applied:**
|
|
||||||
- Added `threading.Lock` (`self._lock`)
|
|
||||||
- Protected all access to `pulse_data` dict
|
|
||||||
- Protected `brain_invocations` counter
|
|
||||||
- Protected `get_status()` method with lock
|
|
||||||
|
|
||||||
## Security Improvements Summary
|
|
||||||
|
|
||||||
| Category | Before | After |
|
|
||||||
|----------|--------|-------|
|
|
||||||
| Path Traversal Protection | ❌ None | ✅ Full validation |
|
|
||||||
| Input Sanitization | ❌ Minimal | ✅ Comprehensive |
|
|
||||||
| Format String Safety | ❌ Vulnerable | ✅ Safe templates |
|
|
||||||
| Command Injection Protection | ❌ Basic | ✅ Validated + timeout |
|
|
||||||
| SQL Injection Protection | ✅ Parameterized | ✅ Parameterized |
|
|
||||||
| Thread Safety | ❌ No locks | ✅ Lock protected |
|
|
||||||
| Credential Handling | ⚠️ Exposed in logs | ✅ Redacted |
|
|
||||||
|
|
||||||
## Remaining Non-Critical Issues
|
|
||||||
|
|
||||||
The following medium/low severity findings remain but do not pose immediate security risks:
|
|
||||||
|
|
||||||
### Medium Severity (Informational)
|
|
||||||
|
|
||||||
1. **No Rate Limiting** (`adapters/runtime.py:84`)
|
|
||||||
- Messages not rate-limited per user
|
|
||||||
- Could lead to API cost abuse
|
|
||||||
- Recommendation: Add per-user rate limiting (e.g., 10 messages/minute)
|
|
||||||
|
|
||||||
2. **User Message Logging** (`adapters/runtime.py:108`)
|
|
||||||
- First 50 chars of messages logged to stdout
|
|
||||||
- May capture sensitive user data
|
|
||||||
- Recommendation: Make message logging configurable, disabled by default
|
|
||||||
|
|
||||||
3. **Placeholder Credentials in Examples**
|
|
||||||
- Example files encourage inline credential replacement
|
|
||||||
- Risk: Accidental commit to version control
|
|
||||||
- Recommendation: All examples already use `os.getenv()` pattern
|
|
||||||
|
|
||||||
4. **SSL Verification Disabled** (`config/pulse_brain_config.py:98`)
|
|
||||||
- UniFi controller check uses `verify=False`
|
|
||||||
- Acceptable for localhost self-signed certificates
|
|
||||||
- Documented with comment
|
|
||||||
|
|
||||||
### Low Severity (Informational)
|
|
||||||
|
|
||||||
1. **No File Permissions on Config Files**
|
|
||||||
- Config files created with default permissions
|
|
||||||
- Recommendation: Set `0o600` on credential files (Linux/macOS)
|
|
||||||
|
|
||||||
2. **Daemon Threads May Lose Data on Shutdown**
|
|
||||||
- All threads are daemon threads
|
|
||||||
- Recommendation: Implement graceful shutdown with thread joins
|
|
||||||
|
|
||||||
## Code Quality Improvements
|
|
||||||
|
|
||||||
In addition to security fixes, the following improvements were made:
|
|
||||||
|
|
||||||
1. **PEP8 Compliance** - All 16 Python files refactored following PEP8 guidelines
|
|
||||||
2. **Type Annotations** - Added return type annotations throughout
|
|
||||||
3. **Code Organization** - Reduced nesting, improved readability
|
|
||||||
4. **Documentation** - Enhanced docstrings and inline comments
|
|
||||||
|
|
||||||
## Positive Security Findings
|
|
||||||
|
|
||||||
The audit found several existing security best practices:
|
|
||||||
|
|
||||||
✅ **SQL Injection Protection** - All database queries use parameterized statements
|
|
||||||
✅ **YAML Safety** - Uses `yaml.safe_load()` (not `yaml.load()`)
|
|
||||||
✅ **No eval/exec** - No dangerous code execution functions
|
|
||||||
✅ **No unsafe deserialization** - No insecure object loading
|
|
||||||
✅ **Subprocess Safety** - Uses list arguments (not shell=True)
|
|
||||||
✅ **Gitignore** - Properly excludes `*.local.yaml` and `.env` files
|
|
||||||
✅ **Environment Variables** - API keys loaded from environment
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Basic functionality testing confirms:
|
|
||||||
- ✅ Code is syntactically correct
|
|
||||||
- ✅ File structure intact
|
|
||||||
- ✅ No import errors introduced
|
|
||||||
- ✅ All modules loadable (pending dependency installation)
|
|
||||||
|
|
||||||
## Recommendations for Deployment
|
|
||||||
|
|
||||||
### Before Production
|
|
||||||
|
|
||||||
1. **Install Dependencies**
|
|
||||||
```powershell
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set API Keys Securely**
|
|
||||||
```powershell
|
|
||||||
$env:ANTHROPIC_API_KEY = "sk-ant-your-key"
|
|
||||||
```
|
|
||||||
Or use Windows Credential Manager
|
|
||||||
|
|
||||||
3. **Review User Mapping**
|
|
||||||
- Map platform user IDs to sanitized usernames
|
|
||||||
- Ensure usernames are alphanumeric + hyphens/underscores only
|
|
||||||
|
|
||||||
4. **Enable Rate Limiting** (if exposing to untrusted users)
|
|
||||||
- Add per-user message rate limiting
|
|
||||||
- Set maximum message queue size
|
|
||||||
|
|
||||||
5. **Restrict File Permissions** (Linux/macOS)
|
|
||||||
```bash
|
|
||||||
chmod 600 config/*.local.yaml
|
|
||||||
chmod 600 memory_workspace/memory_index.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Monitoring
|
|
||||||
|
|
||||||
Monitor for:
|
|
||||||
- Unusual API usage patterns
|
|
||||||
- Failed validation attempts in logs
|
|
||||||
- Large numbers of messages from single users
|
|
||||||
- Unexpected file access patterns
|
|
||||||
|
|
||||||
## Audit Methodology
|
|
||||||
|
|
||||||
The security audit was performed by 5 specialized Opus 4.6 agents:
|
|
||||||
|
|
||||||
1. **Memory System Agent** - Audited `memory_system.py` for SQL injection, path traversal
|
|
||||||
2. **LLM Interface Agent** - Audited `agent.py`, `llm_interface.py` for prompt injection
|
|
||||||
3. **Adapters Agent** - Audited all adapter files for command injection, XSS
|
|
||||||
4. **Monitoring Agent** - Audited `pulse_brain.py`, `heartbeat.py` for code injection
|
|
||||||
5. **Config Agent** - Audited `bot_runner.py`, `config_loader.py` for secrets management
|
|
||||||
|
|
||||||
Each agent:
|
|
||||||
- Performed deep code analysis
|
|
||||||
- Identified specific vulnerabilities with line numbers
|
|
||||||
- Assessed severity and exploitability
|
|
||||||
- Provided detailed remediation recommendations
|
|
||||||
|
|
||||||
Total audit time: ~8 minutes (parallel execution)
|
|
||||||
Total findings: 32
|
|
||||||
Lines of code analyzed: ~3,500+
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### Security Fixes
|
|
||||||
- `memory_system.py` - Path traversal protection, FTS5 sanitization
|
|
||||||
- `pulse_brain.py` - Format string fix, thread safety
|
|
||||||
- `adapters/skill_integration.py` - Command/prompt injection fixes
|
|
||||||
- `config/config_loader.py` - Credential redaction
|
|
||||||
- `config/pulse_brain_config.py` - Template syntax updates
|
|
||||||
|
|
||||||
### No Breaking Changes
|
|
||||||
All fixes maintain backward compatibility with existing functionality. The only user-facing change is that template strings now use `$variable` instead of `{variable}` syntax in pulse brain configurations.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The ajarbot codebase has been thoroughly audited and all critical security vulnerabilities have been remediated. The application is now safe for testing and deployment on Windows 11.
|
|
||||||
|
|
||||||
**Next Steps:**
|
|
||||||
1. Install dependencies: `pip install -r requirements.txt`
|
|
||||||
2. Run basic tests: `python test_installation.py`
|
|
||||||
3. Test with your API key: `python example_usage.py`
|
|
||||||
4. Review deployment guide: `docs/WINDOWS_DEPLOYMENT.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Security Audit Completed:** ✅
|
|
||||||
**Critical Issues Remaining:** 0
|
|
||||||
**Safe for Deployment:** Yes
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Google Calendar API client for managing events."""
|
"""Google Calendar API client for managing events."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List
|
||||||
|
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
@@ -66,7 +66,7 @@ class CalendarClient:
|
|||||||
# Limit days_ahead to 30
|
# Limit days_ahead to 30
|
||||||
days_ahead = min(days_ahead, 30)
|
days_ahead = min(days_ahead, 30)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
time_min = now.isoformat() + "Z"
|
time_min = now.isoformat() + "Z"
|
||||||
time_max = (now + timedelta(days=days_ahead)).isoformat() + "Z"
|
time_max = (now + timedelta(days=days_ahead)).isoformat() + "Z"
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ class CalendarClient:
|
|||||||
else:
|
else:
|
||||||
dt = datetime.fromisoformat(start)
|
dt = datetime.fromisoformat(start)
|
||||||
start_str = dt.strftime("%b %d (all day)")
|
start_str = dt.strftime("%b %d (all day)")
|
||||||
except:
|
except Exception:
|
||||||
start_str = start
|
start_str = start
|
||||||
|
|
||||||
lines.append(f"{i}. {event['summary']} - {start_str}")
|
lines.append(f"{i}. {event['summary']} - {start_str}")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Gmail API client for sending and reading emails."""
|
"""Gmail API client for sending and reading emails."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
@@ -201,13 +203,27 @@ class GmailClient:
|
|||||||
# Get attachment info if any
|
# Get attachment info if any
|
||||||
payload = message.get("payload", {})
|
payload = message.get("payload", {})
|
||||||
attachments = []
|
attachments = []
|
||||||
for part in payload.get("parts", []):
|
|
||||||
if part.get("filename"):
|
def extract_attachments(parts):
|
||||||
attachments.append({
|
"""Recursively extract attachments from message parts."""
|
||||||
"filename": part["filename"],
|
for part in parts:
|
||||||
"mime_type": part.get("mimeType"),
|
filename = part.get("filename")
|
||||||
"size": part.get("body", {}).get("size", 0),
|
if filename:
|
||||||
})
|
body = part.get("body", {})
|
||||||
|
attachment_id = body.get("attachmentId")
|
||||||
|
if attachment_id:
|
||||||
|
attachments.append({
|
||||||
|
"filename": filename,
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"mime_type": part.get("mimeType"),
|
||||||
|
"size": body.get("size", 0),
|
||||||
|
})
|
||||||
|
# Recursively check nested parts
|
||||||
|
if "parts" in part:
|
||||||
|
extract_attachments(part["parts"])
|
||||||
|
|
||||||
|
if "parts" in payload:
|
||||||
|
extract_attachments(payload["parts"])
|
||||||
|
|
||||||
email_data["attachments"] = attachments
|
email_data["attachments"] = attachments
|
||||||
|
|
||||||
@@ -218,3 +234,62 @@ class GmailClient:
|
|||||||
|
|
||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def download_attachment(
|
||||||
|
self,
|
||||||
|
message_id: str,
|
||||||
|
attachment_id: str,
|
||||||
|
filename: str,
|
||||||
|
output_dir: str = "downloads",
|
||||||
|
) -> Dict:
|
||||||
|
"""Download an email attachment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Gmail message ID
|
||||||
|
attachment_id: Attachment ID from the message
|
||||||
|
filename: Original filename of the attachment
|
||||||
|
output_dir: Directory to save the attachment (default: "downloads")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and file path or error
|
||||||
|
"""
|
||||||
|
if not self.service:
|
||||||
|
if not self._initialize_service():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the attachment data
|
||||||
|
attachment = (
|
||||||
|
self.service.users()
|
||||||
|
.messages()
|
||||||
|
.attachments()
|
||||||
|
.get(userId="me", messageId=message_id, id=attachment_id)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decode the attachment data
|
||||||
|
file_data = base64.urlsafe_b64decode(attachment["data"])
|
||||||
|
|
||||||
|
# Create output directory if it doesn't exist
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
file_path = output_path / filename
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(file_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"file_path": str(file_path),
|
||||||
|
"filename": filename,
|
||||||
|
"size": len(file_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
except HttpError as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"Failed to save attachment: {str(e)}"}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import webbrowser
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Thread
|
from typing import Optional
|
||||||
from typing import Dict, Optional
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
@@ -120,14 +119,6 @@ class GoogleOAuthManager:
|
|||||||
|
|
||||||
return self.credentials
|
return self.credentials
|
||||||
|
|
||||||
def needs_refresh_soon(self) -> bool:
|
|
||||||
"""Check if token will expire within 5 minutes."""
|
|
||||||
if not self.credentials or not self.credentials.expiry:
|
|
||||||
return False
|
|
||||||
|
|
||||||
expiry_threshold = datetime.utcnow() + timedelta(minutes=5)
|
|
||||||
return self.credentials.expiry < expiry_threshold
|
|
||||||
|
|
||||||
def run_oauth_flow(self, manual: bool = False) -> bool:
|
def run_oauth_flow(self, manual: bool = False) -> bool:
|
||||||
"""Run OAuth2 authorization flow.
|
"""Run OAuth2 authorization flow.
|
||||||
|
|
||||||
@@ -224,21 +215,3 @@ class GoogleOAuthManager:
|
|||||||
# Atomic rename
|
# Atomic rename
|
||||||
temp_file.replace(self.token_file)
|
temp_file.replace(self.token_file)
|
||||||
|
|
||||||
def revoke_authorization(self) -> bool:
|
|
||||||
"""Revoke OAuth authorization and delete tokens.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if revoked successfully, False otherwise.
|
|
||||||
"""
|
|
||||||
if not self.credentials:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.credentials.revoke(Request())
|
|
||||||
if self.token_file.exists():
|
|
||||||
self.token_file.unlink()
|
|
||||||
print("[OAuth] Authorization revoked successfully")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[OAuth] Failed to revoke authorization: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -193,101 +193,6 @@ class PeopleClient:
|
|||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
def update_contact(self, resource_name: str, updates: Dict) -> Dict:
|
|
||||||
"""Update an existing contact.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resource_name: Contact resource name (e.g., "people/c1234567890")
|
|
||||||
updates: Dict with fields to update (given_name, family_name, email, phone, notes)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status or error
|
|
||||||
"""
|
|
||||||
if not self._ensure_service():
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get current contact to obtain etag
|
|
||||||
current = (
|
|
||||||
self.service.people()
|
|
||||||
.get(resourceName=resource_name, personFields=PERSON_FIELDS)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
|
|
||||||
body: Dict[str, Any] = {"etag": current["etag"]}
|
|
||||||
update_fields = []
|
|
||||||
|
|
||||||
if "given_name" in updates or "family_name" in updates:
|
|
||||||
names = current.get("names", [{}])
|
|
||||||
name = names[0] if names else {}
|
|
||||||
body["names"] = [{
|
|
||||||
"givenName": updates.get("given_name", name.get("givenName", "")),
|
|
||||||
"familyName": updates.get("family_name", name.get("familyName", "")),
|
|
||||||
}]
|
|
||||||
update_fields.append("names")
|
|
||||||
|
|
||||||
if "email" in updates:
|
|
||||||
body["emailAddresses"] = [{"value": updates["email"]}]
|
|
||||||
update_fields.append("emailAddresses")
|
|
||||||
|
|
||||||
if "phone" in updates:
|
|
||||||
body["phoneNumbers"] = [{"value": updates["phone"]}]
|
|
||||||
update_fields.append("phoneNumbers")
|
|
||||||
|
|
||||||
if "notes" in updates:
|
|
||||||
body["biographies"] = [{"value": updates["notes"], "contentType": "TEXT_PLAIN"}]
|
|
||||||
update_fields.append("biographies")
|
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
return {"success": False, "error": "No valid fields to update"}
|
|
||||||
|
|
||||||
result = (
|
|
||||||
self.service.people()
|
|
||||||
.updateContact(
|
|
||||||
resourceName=resource_name,
|
|
||||||
body=body,
|
|
||||||
updatePersonFields=",".join(update_fields),
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"resource_name": result.get("resourceName", resource_name),
|
|
||||||
"updated_fields": update_fields,
|
|
||||||
}
|
|
||||||
|
|
||||||
except HttpError as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
||||||
def delete_contact(self, resource_name: str) -> Dict:
|
|
||||||
"""Delete a contact.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resource_name: Contact resource name (e.g., "people/c1234567890")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status or error
|
|
||||||
"""
|
|
||||||
if not self._ensure_service():
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "Not authorized. Run: python bot_runner.py --setup-google",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.service.people().deleteContact(
|
|
||||||
resourceName=resource_name,
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
return {"success": True, "deleted": resource_name}
|
|
||||||
|
|
||||||
except HttpError as e:
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
||||||
def _format_contact(self, person: Dict) -> Dict:
|
def _format_contact(self, person: Dict) -> Dict:
|
||||||
"""Format a person resource into a simple contact dict."""
|
"""Format a person resource into a simple contact dict."""
|
||||||
names = person.get("names", [])
|
names = person.get("names", [])
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Utility functions for Gmail/Calendar tools."""
|
"""Utility functions for Gmail/Calendar tools."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import email
|
|
||||||
import re
|
import re
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
@@ -193,12 +192,13 @@ def format_email_summary(emails: List[Dict], include_body: bool = False) -> str:
|
|||||||
lines.append(f"{i}. From: {email_data['from']}")
|
lines.append(f"{i}. From: {email_data['from']}")
|
||||||
lines.append(f" Subject: {email_data['subject']}")
|
lines.append(f" Subject: {email_data['subject']}")
|
||||||
lines.append(f" Date: {email_data['date']}")
|
lines.append(f" Date: {email_data['date']}")
|
||||||
|
lines.append(f" Message-ID: {email_data.get('id', 'N/A')}")
|
||||||
|
|
||||||
if include_body and "body" in email_data:
|
if include_body and "body" in email_data:
|
||||||
# Truncate long bodies
|
# Truncate long bodies
|
||||||
body = email_data["body"]
|
body = email_data["body"]
|
||||||
if len(body) > 500:
|
if len(body) > 2000:
|
||||||
body = body[:500] + "..."
|
body = body[:2000] + "..."
|
||||||
lines.append(f" Body: {body}")
|
lines.append(f" Body: {body}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" Snippet: {email_data['snippet']}")
|
lines.append(f" Snippet: {email_data['snippet']}")
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
Supports two modes for Claude:
|
Supports two modes for Claude:
|
||||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||||
- Set USE_AGENT_SDK=true (default)
|
- Set USE_AGENT_SDK=true (default)
|
||||||
- Model: claude-sonnet-4-5-20250929 (default for all operations)
|
- Model: claude-sonnet-4-6 (default for all operations)
|
||||||
- All tools are MCP-based (no API key needed)
|
- All tools are MCP-based (no API key needed)
|
||||||
- Tools registered via mcp_tools.py MCP server
|
- Tools registered via mcp_tools.py MCP server
|
||||||
- Flat-rate subscription cost
|
- Flat-rate subscription cost
|
||||||
|
|
||||||
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
||||||
- Model: claude-sonnet-4-5-20250929
|
- Model: claude-sonnet-4-6
|
||||||
- Requires ANTHROPIC_API_KEY in .env
|
- Requires ANTHROPIC_API_KEY in .env
|
||||||
- Uses traditional tool definitions from tools.py
|
- Uses traditional tool definitions from tools.py
|
||||||
"""
|
"""
|
||||||
@@ -41,6 +41,7 @@ if not logger.handlers:
|
|||||||
# Try to import Agent SDK (optional dependency)
|
# Try to import Agent SDK (optional dependency)
|
||||||
try:
|
try:
|
||||||
from claude_agent_sdk import (
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
ClaudeAgentOptions,
|
ClaudeAgentOptions,
|
||||||
ResultMessage,
|
ResultMessage,
|
||||||
)
|
)
|
||||||
@@ -60,8 +61,8 @@ _USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
|||||||
|
|
||||||
# Default models by provider
|
# Default models by provider
|
||||||
_DEFAULT_MODELS = {
|
_DEFAULT_MODELS = {
|
||||||
"claude": "claude-sonnet-4-5-20250929",
|
"claude": "claude-sonnet-4-6",
|
||||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
"claude_agent_sdk": "claude-sonnet-4-6",
|
||||||
"glm": "glm-4-plus",
|
"glm": "glm-4-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +148,9 @@ class LLMInterface:
|
|||||||
# Set model based on mode
|
# Set model based on mode
|
||||||
if provider == "claude":
|
if provider == "claude":
|
||||||
if self.mode == "agent_sdk":
|
if self.mode == "agent_sdk":
|
||||||
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-5-20250929")
|
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-6")
|
||||||
else:
|
else:
|
||||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-6")
|
||||||
else:
|
else:
|
||||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||||
|
|
||||||
@@ -345,8 +346,12 @@ class LLMInterface:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from mcp_tools import file_system_server
|
from mcp_tools import file_system_server
|
||||||
|
from mcp_servers.mcp_ssh import ssh_mcp_server
|
||||||
|
|
||||||
mcp_servers = {"file_system": file_system_server}
|
mcp_servers = {
|
||||||
|
"file_system": file_system_server,
|
||||||
|
"ssh": ssh_mcp_server,
|
||||||
|
}
|
||||||
|
|
||||||
# All tools registered in the MCP server
|
# All tools registered in the MCP server
|
||||||
allowed_tools = [
|
allowed_tools = [
|
||||||
@@ -356,6 +361,9 @@ class LLMInterface:
|
|||||||
"edit_file",
|
"edit_file",
|
||||||
"list_directory",
|
"list_directory",
|
||||||
"run_command",
|
"run_command",
|
||||||
|
# SSH tools
|
||||||
|
"ssh_execute",
|
||||||
|
"ssh_file_upload",
|
||||||
# Web tool
|
# Web tool
|
||||||
"web_fetch",
|
"web_fetch",
|
||||||
# Zettelkasten tools
|
# Zettelkasten tools
|
||||||
@@ -385,7 +393,7 @@ class LLMInterface:
|
|||||||
|
|
||||||
# Conditionally add Obsidian MCP server
|
# Conditionally add Obsidian MCP server
|
||||||
try:
|
try:
|
||||||
from obsidian_mcp import (
|
from mcp_servers.obsidian.obsidian_mcp import (
|
||||||
is_obsidian_enabled,
|
is_obsidian_enabled,
|
||||||
check_obsidian_health,
|
check_obsidian_health,
|
||||||
get_obsidian_server_config,
|
get_obsidian_server_config,
|
||||||
@@ -404,6 +412,46 @@ class LLMInterface:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LLM] Obsidian MCP unavailable: {e}")
|
print(f"[LLM] Obsidian MCP unavailable: {e}")
|
||||||
|
|
||||||
|
# Conditionally add Cloudflare Code Mode MCP server
|
||||||
|
try:
|
||||||
|
from mcp_servers.cloudflare.cloudflare_mcp import (
|
||||||
|
is_cloudflare_enabled,
|
||||||
|
get_cloudflare_server_config,
|
||||||
|
CLOUDFLARE_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_cloudflare_enabled():
|
||||||
|
cloudflare_config = get_cloudflare_server_config()
|
||||||
|
mcp_servers["cloudflare"] = cloudflare_config
|
||||||
|
allowed_tools.extend(CLOUDFLARE_TOOLS)
|
||||||
|
print("[LLM] Cloudflare MCP server registered (2 tools: search, execute)")
|
||||||
|
else:
|
||||||
|
print("[LLM] Cloudflare MCP disabled or no API token set")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM] Cloudflare MCP unavailable: {e}")
|
||||||
|
|
||||||
|
# Conditionally add Loki MCP server (homelab log querying)
|
||||||
|
try:
|
||||||
|
from mcp_servers.loki.loki_mcp import (
|
||||||
|
is_loki_enabled,
|
||||||
|
get_loki_server_config,
|
||||||
|
LOKI_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_loki_enabled():
|
||||||
|
loki_config = get_loki_server_config()
|
||||||
|
mcp_servers["loki"] = loki_config
|
||||||
|
allowed_tools.extend(LOKI_TOOLS)
|
||||||
|
print(f"[LLM] Loki MCP server registered ({len(LOKI_TOOLS)} tools: {', '.join(LOKI_TOOLS)})")
|
||||||
|
else:
|
||||||
|
print("[LLM] Loki MCP disabled")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM] Loki MCP unavailable: {e}")
|
||||||
|
|
||||||
def _stderr_callback(line: str) -> None:
|
def _stderr_callback(line: str) -> None:
|
||||||
"""Log Claude CLI stderr for debugging transport failures."""
|
"""Log Claude CLI stderr for debugging transport failures."""
|
||||||
logger.debug("[CLI stderr] %s", line)
|
logger.debug("[CLI stderr] %s", line)
|
||||||
@@ -505,6 +553,7 @@ class LLMInterface:
|
|||||||
|
|
||||||
# --- 4. Consume messages until we get a ResultMessage. ---
|
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||||
result_text = ""
|
result_text = ""
|
||||||
|
assistant_messages = [] # Collect assistant responses
|
||||||
message_count = 0
|
message_count = 0
|
||||||
async for data in query_obj.receive_messages():
|
async for data in query_obj.receive_messages():
|
||||||
message = parse_message(data)
|
message = parse_message(data)
|
||||||
@@ -514,14 +563,36 @@ class LLMInterface:
|
|||||||
message_type = type(message).__name__
|
message_type = type(message).__name__
|
||||||
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
||||||
|
|
||||||
|
# Collect text from AssistantMessage objects
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
logger.debug(f"[LLM] AssistantMessage: has_content={hasattr(message, 'content')}")
|
||||||
|
if hasattr(message, 'content') and message.content:
|
||||||
|
# Extract text from content blocks
|
||||||
|
if isinstance(message.content, str):
|
||||||
|
assistant_messages.append(message.content)
|
||||||
|
logger.debug(f"[LLM] → Collected string: {len(message.content)} chars")
|
||||||
|
elif isinstance(message.content, list):
|
||||||
|
for block in message.content:
|
||||||
|
if hasattr(block, 'type') and block.type == 'text':
|
||||||
|
if hasattr(block, 'text'):
|
||||||
|
assistant_messages.append(block.text)
|
||||||
|
logger.debug(f"[LLM] → Collected text block: {len(block.text)} chars")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[LLM] → AssistantMessage has no content or empty")
|
||||||
|
|
||||||
if isinstance(message, ResultMessage):
|
if isinstance(message, ResultMessage):
|
||||||
result_text = message.result or ""
|
# Use ResultMessage.result if available, otherwise use collected assistant messages
|
||||||
|
result_text = message.result or "\n".join(assistant_messages)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||||
message_count,
|
message_count,
|
||||||
getattr(message, "total_cost_usd", 0),
|
getattr(message, "total_cost_usd", 0),
|
||||||
getattr(message, "num_turns", "?"),
|
getattr(message, "num_turns", "?"),
|
||||||
)
|
)
|
||||||
|
if not message.result and assistant_messages:
|
||||||
|
logger.info(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages")
|
||||||
|
elif not message.result and not assistant_messages:
|
||||||
|
logger.warning(f"[LLM] PROBLEM: Both ResultMessage.result and assistant_messages are empty!")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Log non-result messages to detect loops
|
# Log non-result messages to detect loops
|
||||||
|
|||||||
1
mcp_servers/__init__.py
Normal file
1
mcp_servers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# mcp_servers - Standalone MCP server packages
|
||||||
9
mcp_servers/cloudflare/__init__.py
Normal file
9
mcp_servers/cloudflare/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Cloudflare Code Mode MCP Server
|
||||||
|
#
|
||||||
|
# Remote MCP server at https://mcp.cloudflare.com/mcp
|
||||||
|
# Uses "Code Mode" — 2 tools (search + execute) covering the entire
|
||||||
|
# Cloudflare API (2,500+ endpoints) in ~1,000 tokens.
|
||||||
|
#
|
||||||
|
# Auth: Cloudflare API Token (Bearer header via mcp-remote bridge)
|
||||||
|
# Docs: https://blog.cloudflare.com/code-mode-mcp/
|
||||||
|
# Repo: https://github.com/cloudflare/mcp
|
||||||
81
mcp_servers/cloudflare/cloudflare_mcp.py
Normal file
81
mcp_servers/cloudflare/cloudflare_mcp.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Cloudflare Code Mode MCP Server Integration.
|
||||||
|
|
||||||
|
Manages the remote Cloudflare MCP server connection via mcp-remote bridge.
|
||||||
|
The server exposes the entire Cloudflare API (2,500+ endpoints) through
|
||||||
|
just two tools: search() and execute(), using ~1,000 tokens total.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
Your bot → npx mcp-remote → https://mcp.cloudflare.com/mcp
|
||||||
|
Auth is via Cloudflare API Token passed as Bearer header.
|
||||||
|
|
||||||
|
Pattern mirrors obsidian_mcp.py for consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> Dict[str, Any]:
|
||||||
|
"""Load Cloudflare MCP configuration from environment."""
|
||||||
|
from mcp_servers.cloudflare.config import (
|
||||||
|
CLOUDFLARE_API_TOKEN,
|
||||||
|
CLOUDFLARE_MCP_URL,
|
||||||
|
CLOUDFLARE_MCP_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": CLOUDFLARE_MCP_ENABLED,
|
||||||
|
"api_token": CLOUDFLARE_API_TOKEN,
|
||||||
|
"mcp_url": CLOUDFLARE_MCP_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_cloudflare_enabled() -> bool:
|
||||||
|
"""Check if the Cloudflare MCP integration is enabled and has a token."""
|
||||||
|
config = _load_config()
|
||||||
|
if not config["enabled"]:
|
||||||
|
return False
|
||||||
|
if not config["api_token"]:
|
||||||
|
logger.warning(
|
||||||
|
"[Cloudflare MCP] Enabled but CLOUDFLARE_API_TOKEN is not set"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_cloudflare_server_config() -> Dict[str, Any]:
|
||||||
|
"""Build the MCP server configuration for Agent SDK registration.
|
||||||
|
|
||||||
|
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||||
|
Uses npx mcp-remote as a stdio bridge to the remote Cloudflare server.
|
||||||
|
|
||||||
|
The API token is passed via the --header flag as a Bearer token.
|
||||||
|
"""
|
||||||
|
config = _load_config()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
config["mcp_url"],
|
||||||
|
"--header",
|
||||||
|
f"Authorization: Bearer {config['api_token']}",
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
# Pass through any needed env vars for npx/node resolution
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
"HOME": os.environ.get("HOME", os.environ.get("USERPROFILE", "")),
|
||||||
|
"APPDATA": os.environ.get("APPDATA", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Tools exposed by the Cloudflare Code Mode MCP server.
|
||||||
|
# These are the only two tools — that's the whole point of Code Mode.
|
||||||
|
CLOUDFLARE_TOOLS: List[str] = [
|
||||||
|
"search",
|
||||||
|
"execute",
|
||||||
|
]
|
||||||
37
mcp_servers/cloudflare/config.py
Normal file
37
mcp_servers/cloudflare/config.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Cloudflare Code Mode MCP Server - Configuration
|
||||||
|
|
||||||
|
Remote MCP server that exposes the entire Cloudflare API through just two tools:
|
||||||
|
- search(): Query the OpenAPI spec to find endpoints
|
||||||
|
- execute(): Run JavaScript against the Cloudflare API
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
CLOUDFLARE_API_TOKEN - Your Cloudflare API token (required)
|
||||||
|
CLOUDFLARE_MCP_URL - Remote MCP server URL (default: https://mcp.cloudflare.com/mcp)
|
||||||
|
CLOUDFLARE_MCP_ENABLED - Enable/disable integration (default: true)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Cloudflare API token — create one at https://dash.cloudflare.com/profile/api-tokens
|
||||||
|
# Recommended permissions: Account Resources (Read) + whatever you need
|
||||||
|
CLOUDFLARE_API_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
|
||||||
|
|
||||||
|
# The remote MCP server URL (Cloudflare runs this as a Worker)
|
||||||
|
CLOUDFLARE_MCP_URL = os.getenv(
|
||||||
|
"CLOUDFLARE_MCP_URL", "https://mcp.cloudflare.com/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Set to "false" to disable the integration without removing config
|
||||||
|
CLOUDFLARE_MCP_ENABLED = os.getenv(
|
||||||
|
"CLOUDFLARE_MCP_ENABLED", "true"
|
||||||
|
).lower() in ("true", "1", "yes")
|
||||||
7
mcp_servers/loki/__init__.py
Normal file
7
mcp_servers/loki/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Loki MCP Server - Query homelab logs via Loki's HTTP API
|
||||||
|
#
|
||||||
|
# Modules:
|
||||||
|
# config.py - Environment-based configuration
|
||||||
|
# loki_client.py - Async HTTP client for Loki API
|
||||||
|
# loki_mcp.py - Integration module for Agent SDK registration
|
||||||
|
# loki_server.py - MCP server with tool definitions (runs as subprocess)
|
||||||
44
mcp_servers/loki/config.py
Normal file
44
mcp_servers/loki/config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Loki MCP Server - Configuration
|
||||||
|
|
||||||
|
Settings for connecting to your Loki instance via its HTTP API.
|
||||||
|
Uses environment variables with sensible defaults.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
LOKI_URL - Base URL for your Loki instance
|
||||||
|
LOKI_TIMEOUT - Request timeout in seconds (default: 30)
|
||||||
|
LOKI_DEFAULT_LIMIT - Default number of log lines to return (default: 100)
|
||||||
|
LOKI_MCP_ENABLED - Enable/disable integration (default: true)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The URL where Loki is reachable (through Caddy reverse proxy).
|
||||||
|
LOKI_URL = os.getenv("LOKI_URL", "https://loki.apophisnetworking.net")
|
||||||
|
|
||||||
|
# How long (seconds) to wait for Loki to respond before giving up.
|
||||||
|
LOKI_TIMEOUT = int(os.getenv("LOKI_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Query defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How many log lines to return if the caller doesn't specify.
|
||||||
|
DEFAULT_LIMIT = int(os.getenv("LOKI_DEFAULT_LIMIT", "100"))
|
||||||
|
|
||||||
|
# Default time range for queries if none specified (in hours).
|
||||||
|
DEFAULT_RANGE_HOURS = 1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature flag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Set to "false" to disable the integration without removing config
|
||||||
|
LOKI_MCP_ENABLED = os.getenv(
|
||||||
|
"LOKI_MCP_ENABLED", "true"
|
||||||
|
).lower() in ("true", "1", "yes")
|
||||||
189
mcp_servers/loki/loki_client.py
Normal file
189
mcp_servers/loki/loki_client.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""Loki HTTP API Client.
|
||||||
|
|
||||||
|
Handles all communication with Loki's query endpoints:
|
||||||
|
- /loki/api/v1/query_range (log queries over a time window)
|
||||||
|
- /loki/api/v1/labels (list all label names)
|
||||||
|
- /loki/api/v1/label/{name}/values (values for a specific label)
|
||||||
|
- /loki/api/v1/series (active label sets / streams)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from mcp_servers.loki.config import (
|
||||||
|
LOKI_URL,
|
||||||
|
LOKI_TIMEOUT,
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
DEFAULT_RANGE_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LokiClient:
|
||||||
|
"""Async HTTP client for Loki's query API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
):
|
||||||
|
self.url = url or LOKI_URL
|
||||||
|
self.timeout = timeout or LOKI_TIMEOUT
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Lazy-init the async HTTP client."""
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.url,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client."""
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Time helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_nano_ts(dt: datetime) -> str:
|
||||||
|
"""Convert a datetime to Loki's nanosecond-epoch string."""
|
||||||
|
return str(int(dt.timestamp() * 1_000_000_000))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_range(hours: Optional[int] = None):
|
||||||
|
"""Return (start, end) as nano-epoch strings for the last N hours."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hrs = hours or DEFAULT_RANGE_HOURS
|
||||||
|
start = now - timedelta(hours=hrs)
|
||||||
|
return (
|
||||||
|
LokiClient._to_nano_ts(start),
|
||||||
|
LokiClient._to_nano_ts(now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Core queries
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def query_range(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
start: Optional[str] = None,
|
||||||
|
end: Optional[str] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
hours: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Run a LogQL query over a time range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: LogQL expression, e.g. '{job="varlogs"} |= "error"'
|
||||||
|
start: Nano-epoch start (optional — defaults to now-<hours>).
|
||||||
|
end: Nano-epoch end (optional — defaults to now).
|
||||||
|
limit: Max log lines to return.
|
||||||
|
hours: Shorthand for "last N hours" (ignored if start/end given).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw JSON response from Loki (dict).
|
||||||
|
"""
|
||||||
|
if not start or not end:
|
||||||
|
start, end = self._default_range(hours)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"query": query,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"limit": limit or DEFAULT_LIMIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
client = await self._get_client()
|
||||||
|
logger.debug("[Loki] query_range: %s", params)
|
||||||
|
resp = await client.get("/loki/api/v1/query_range", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def labels(self) -> List[str]:
|
||||||
|
"""List all known label names."""
|
||||||
|
client = await self._get_client()
|
||||||
|
resp = await client.get("/loki/api/v1/labels")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
async def label_values(self, label: str) -> List[str]:
|
||||||
|
"""List values for a specific label (e.g. 'job', 'host')."""
|
||||||
|
client = await self._get_client()
|
||||||
|
resp = await client.get(f"/loki/api/v1/label/{label}/values")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
async def series(
|
||||||
|
self,
|
||||||
|
match: Optional[List[str]] = None,
|
||||||
|
hours: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""List active streams/series matching optional selectors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
match: List of LogQL stream selectors, e.g. ['{job="varlogs"}'].
|
||||||
|
hours: Time window to search (default: DEFAULT_RANGE_HOURS).
|
||||||
|
"""
|
||||||
|
start, end = self._default_range(hours)
|
||||||
|
params: Dict[str, Any] = {"start": start, "end": end}
|
||||||
|
if match:
|
||||||
|
params["match[]"] = match
|
||||||
|
|
||||||
|
client = await self._get_client()
|
||||||
|
resp = await client.get("/loki/api/v1/series", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
async def health(self) -> bool:
|
||||||
|
"""Quick health check — hits /ready endpoint."""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
resp = await client.get("/ready")
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[Loki] Health check failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Convenience: extract just the log lines from a query_range result
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_lines(result: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||||
|
"""Pull log lines from a query_range response.
|
||||||
|
|
||||||
|
Returns list of {"timestamp": ..., "line": ..., "labels": ...} dicts,
|
||||||
|
sorted newest-first.
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
data = result.get("data", {})
|
||||||
|
for stream in data.get("result", []):
|
||||||
|
labels = stream.get("stream", {})
|
||||||
|
label_str = ", ".join(f'{k}="{v}"' for k, v in labels.items())
|
||||||
|
for ts, line in stream.get("values", []):
|
||||||
|
# Convert nano-epoch to human-readable
|
||||||
|
dt = datetime.fromtimestamp(
|
||||||
|
int(ts) / 1_000_000_000, tz=timezone.utc
|
||||||
|
)
|
||||||
|
lines.append({
|
||||||
|
"timestamp": dt.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
|
"line": line,
|
||||||
|
"labels": label_str,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Newest first
|
||||||
|
lines.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
|
return lines
|
||||||
80
mcp_servers/loki/loki_mcp.py
Normal file
80
mcp_servers/loki/loki_mcp.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Loki MCP Server Integration.
|
||||||
|
|
||||||
|
Manages the local Loki MCP server that exposes homelab log querying
|
||||||
|
through MCP tools. Unlike Cloudflare (remote via mcp-remote), this runs
|
||||||
|
a local Python MCP server that talks to Loki's HTTP API directly.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
Garvis → (stdio) → loki_server.py → HTTP → Loki (loki.apophisnetworking.net)
|
||||||
|
|
||||||
|
Pattern mirrors cloudflare_mcp.py for consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> Dict[str, Any]:
|
||||||
|
"""Load Loki MCP configuration from environment."""
|
||||||
|
from mcp_servers.loki.config import (
|
||||||
|
LOKI_URL,
|
||||||
|
LOKI_TIMEOUT,
|
||||||
|
LOKI_MCP_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": LOKI_MCP_ENABLED,
|
||||||
|
"url": LOKI_URL,
|
||||||
|
"timeout": LOKI_TIMEOUT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_loki_enabled() -> bool:
|
||||||
|
"""Check if the Loki MCP integration is enabled."""
|
||||||
|
config = _load_config()
|
||||||
|
if not config["enabled"]:
|
||||||
|
return False
|
||||||
|
if not config["url"]:
|
||||||
|
logger.warning("[Loki MCP] Enabled but LOKI_URL is not set")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_loki_server_config() -> Dict[str, Any]:
|
||||||
|
"""Build the MCP server configuration for Agent SDK registration.
|
||||||
|
|
||||||
|
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||||
|
This runs a local Python MCP server via stdio (not mcp-remote).
|
||||||
|
"""
|
||||||
|
# Path to the MCP server script
|
||||||
|
server_script = os.path.join(
|
||||||
|
os.path.dirname(__file__), "loki_server.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"command": sys.executable, # Use the same Python interpreter
|
||||||
|
"args": [server_script],
|
||||||
|
"env": {
|
||||||
|
"PATH": os.environ.get("PATH", ""),
|
||||||
|
"HOME": os.environ.get("HOME", os.environ.get("USERPROFILE", "")),
|
||||||
|
"APPDATA": os.environ.get("APPDATA", ""),
|
||||||
|
# Pass Loki config through to the subprocess
|
||||||
|
"LOKI_URL": os.environ.get("LOKI_URL", "https://loki.apophisnetworking.net"),
|
||||||
|
"LOKI_TIMEOUT": os.environ.get("LOKI_TIMEOUT", "30"),
|
||||||
|
"LOKI_DEFAULT_LIMIT": os.environ.get("LOKI_DEFAULT_LIMIT", "100"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Tools exposed by the Loki MCP server.
|
||||||
|
LOKI_TOOLS: List[str] = [
|
||||||
|
"loki_query",
|
||||||
|
"loki_labels",
|
||||||
|
"loki_label_values",
|
||||||
|
"loki_series",
|
||||||
|
"loki_health",
|
||||||
|
]
|
||||||
273
mcp_servers/loki/loki_server.py
Normal file
273
mcp_servers/loki/loki_server.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""Loki MCP Server — Exposes homelab log querying via MCP tools.
|
||||||
|
|
||||||
|
Runs as a stdio-based MCP server. The Agent SDK spawns this as a
|
||||||
|
subprocess and communicates via JSON-RPC over stdin/stdout.
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
loki_query - Run a LogQL query and get log lines
|
||||||
|
loki_labels - List all known label names
|
||||||
|
loki_label_values - Get values for a specific label
|
||||||
|
loki_series - List active log streams
|
||||||
|
loki_health - Check if Loki is reachable
|
||||||
|
|
||||||
|
Usage (standalone test):
|
||||||
|
python loki_server.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add parent paths so imports work when run as subprocess
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
|
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
|
||||||
|
from mcp_servers.loki.loki_client import LokiClient
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create the MCP server
|
||||||
|
server = Server("loki")
|
||||||
|
|
||||||
|
# Create the Loki client (uses env vars from config)
|
||||||
|
client = LokiClient()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Tool definitions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
"""Return the list of tools this server exposes."""
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="loki_query",
|
||||||
|
description=(
|
||||||
|
"Query logs from Loki using LogQL. "
|
||||||
|
"Examples: '{job=\"varlogs\"} |= \"error\"', "
|
||||||
|
"'{container=\"caddy\"} | json | status >= 500'. "
|
||||||
|
"Returns log lines with timestamps and labels."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"LogQL query expression. Must include a stream selector "
|
||||||
|
"in curly braces. Examples:\n"
|
||||||
|
" {job=\"varlogs\"}\n"
|
||||||
|
" {container=\"caddy\"} |= \"error\"\n"
|
||||||
|
" {host=\"proxmox\"} | json | level=\"error\""
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"hours": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "How many hours back to search (default: 1)",
|
||||||
|
"default": 1,
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max number of log lines to return (default: 100)",
|
||||||
|
"default": 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="loki_labels",
|
||||||
|
description=(
|
||||||
|
"List all known label names in Loki. Use this to discover "
|
||||||
|
"what labels are available for querying (e.g. job, host, "
|
||||||
|
"container, filename)."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="loki_label_values",
|
||||||
|
description=(
|
||||||
|
"Get all values for a specific label. Useful for discovering "
|
||||||
|
"what jobs, hosts, or containers are sending logs. "
|
||||||
|
"Example: label='job' might return ['varlogs', 'caddy', 'grafana']."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The label name to get values for (e.g. 'job', 'host', 'container')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["label"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="loki_series",
|
||||||
|
description=(
|
||||||
|
"List active log streams/series. Shows what label combinations "
|
||||||
|
"are actively producing logs. Optionally filter with a stream selector."
|
||||||
|
),
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"match": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional LogQL stream selector to filter, e.g. '{job=\"varlogs\"}'",
|
||||||
|
},
|
||||||
|
"hours": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "How many hours back to search (default: 1)",
|
||||||
|
"default": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="loki_health",
|
||||||
|
description="Check if Loki is reachable and responding.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
"""Handle tool calls."""
|
||||||
|
try:
|
||||||
|
if name == "loki_query":
|
||||||
|
return await _handle_query(arguments)
|
||||||
|
elif name == "loki_labels":
|
||||||
|
return await _handle_labels()
|
||||||
|
elif name == "loki_label_values":
|
||||||
|
return await _handle_label_values(arguments)
|
||||||
|
elif name == "loki_series":
|
||||||
|
return await _handle_series(arguments)
|
||||||
|
elif name == "loki_health":
|
||||||
|
return await _handle_health()
|
||||||
|
else:
|
||||||
|
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[Loki MCP] Error in %s: %s", name, e, exc_info=True)
|
||||||
|
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Tool handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _handle_query(args: dict) -> list[TextContent]:
|
||||||
|
"""Run a LogQL query and return formatted log lines."""
|
||||||
|
query = args["query"]
|
||||||
|
hours = args.get("hours", 1)
|
||||||
|
limit = args.get("limit", 100)
|
||||||
|
|
||||||
|
result = await client.query_range(query=query, hours=hours, limit=limit)
|
||||||
|
|
||||||
|
# Check for errors in the response
|
||||||
|
if result.get("status") != "success":
|
||||||
|
error_msg = result.get("message", "Unknown error")
|
||||||
|
return [TextContent(type="text", text=f"Loki query error: {error_msg}")]
|
||||||
|
|
||||||
|
# Extract readable log lines
|
||||||
|
lines = client.extract_lines(result)
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return [TextContent(
|
||||||
|
type="text",
|
||||||
|
text=f"No logs found for query: {query} (last {hours}h)",
|
||||||
|
)]
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
output_parts = [f"Found {len(lines)} log lines for: {query} (last {hours}h)\n"]
|
||||||
|
for entry in lines:
|
||||||
|
output_parts.append(
|
||||||
|
f"[{entry['timestamp']}] ({entry['labels']}) {entry['line']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Truncate if too long (prevent token explosion)
|
||||||
|
output = "\n".join(output_parts)
|
||||||
|
if len(output) > 15000:
|
||||||
|
output = output[:15000] + f"\n\n... truncated ({len(lines)} total lines)"
|
||||||
|
|
||||||
|
return [TextContent(type="text", text=output)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_labels() -> list[TextContent]:
|
||||||
|
"""List all label names."""
|
||||||
|
labels = await client.labels()
|
||||||
|
if not labels:
|
||||||
|
return [TextContent(type="text", text="No labels found in Loki.")]
|
||||||
|
|
||||||
|
output = f"Available labels ({len(labels)}):\n" + "\n".join(f" - {l}" for l in sorted(labels))
|
||||||
|
return [TextContent(type="text", text=output)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_label_values(args: dict) -> list[TextContent]:
|
||||||
|
"""Get values for a specific label."""
|
||||||
|
label = args["label"]
|
||||||
|
values = await client.label_values(label)
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return [TextContent(type="text", text=f"No values found for label '{label}'.")]
|
||||||
|
|
||||||
|
output = f"Values for '{label}' ({len(values)}):\n" + "\n".join(f" - {v}" for v in sorted(values))
|
||||||
|
return [TextContent(type="text", text=output)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_series(args: dict) -> list[TextContent]:
|
||||||
|
"""List active streams/series."""
|
||||||
|
match_str = args.get("match")
|
||||||
|
hours = args.get("hours", 1)
|
||||||
|
|
||||||
|
match_list = [match_str] if match_str else None
|
||||||
|
series = await client.series(match=match_list, hours=hours)
|
||||||
|
|
||||||
|
if not series:
|
||||||
|
return [TextContent(type="text", text="No active series found.")]
|
||||||
|
|
||||||
|
output_parts = [f"Active streams ({len(series)}):"]
|
||||||
|
for s in series[:50]: # Cap at 50 to avoid huge output
|
||||||
|
labels_str = ", ".join(f'{k}="{v}"' for k, v in s.items())
|
||||||
|
output_parts.append(f" {{{labels_str}}}")
|
||||||
|
|
||||||
|
if len(series) > 50:
|
||||||
|
output_parts.append(f"\n ... and {len(series) - 50} more")
|
||||||
|
|
||||||
|
return [TextContent(type="text", text="\n".join(output_parts))]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_health() -> list[TextContent]:
|
||||||
|
"""Check Loki health."""
|
||||||
|
healthy = await client.health()
|
||||||
|
if healthy:
|
||||||
|
return [TextContent(type="text", text=f"✅ Loki is healthy and reachable at {client.url}")]
|
||||||
|
else:
|
||||||
|
return [TextContent(type="text", text=f"❌ Loki is NOT reachable at {client.url}")]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run the MCP server over stdio."""
|
||||||
|
logger.info("[Loki MCP] Starting server (Loki URL: %s)", client.url)
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
261
mcp_servers/mcp_ssh.py
Normal file
261
mcp_servers/mcp_ssh.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""SSH MCP Server for remote command execution via SSH.
|
||||||
|
|
||||||
|
Provides SSH access to remote hosts for the bot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
PARAMIKO_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PARAMIKO_AVAILABLE = False
|
||||||
|
|
||||||
|
from claude_agent_sdk import tool, create_sdk_mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="ssh_execute",
|
||||||
|
description="Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
||||||
|
input_schema={
|
||||||
|
"host": str,
|
||||||
|
"username": str,
|
||||||
|
"password": str,
|
||||||
|
"key_filename": str,
|
||||||
|
"command": str,
|
||||||
|
"port": int,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def ssh_execute(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute a command on a remote host via SSH."""
|
||||||
|
if not PARAMIKO_AVAILABLE:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
host = args.get("host")
|
||||||
|
username = args.get("username")
|
||||||
|
password = args.get("password")
|
||||||
|
key_filename = args.get("key_filename")
|
||||||
|
command = args.get("command")
|
||||||
|
port = args.get("port", 22)
|
||||||
|
|
||||||
|
if not all([host, username, command]):
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: Missing required parameters: host, username, command"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
if not password and not key_filename:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run SSH command in thread pool to avoid blocking
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
_execute_ssh_sync,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key_filename,
|
||||||
|
command
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format result as MCP-compliant text content
|
||||||
|
if result["success"]:
|
||||||
|
output_parts = [f"SSH command executed on {result['host']} (auth: {result['auth_method']})"]
|
||||||
|
output_parts.append(f"Exit code: {result['exit_code']}")
|
||||||
|
|
||||||
|
if result["stdout"]:
|
||||||
|
stdout = result["stdout"]
|
||||||
|
if len(stdout) > 5000:
|
||||||
|
stdout = stdout[:5000] + "\n... (stdout truncated)"
|
||||||
|
output_parts.append(f"\nSTDOUT:\n{stdout}")
|
||||||
|
|
||||||
|
if result["stderr"]:
|
||||||
|
stderr = result["stderr"]
|
||||||
|
if len(stderr) > 5000:
|
||||||
|
stderr = stderr[:5000] + "\n... (stderr truncated)"
|
||||||
|
output_parts.append(f"\nSTDERR:\n{stderr}")
|
||||||
|
|
||||||
|
if not result["stdout"] and not result["stderr"]:
|
||||||
|
output_parts.append("\n(no output)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "\n".join(output_parts)}],
|
||||||
|
"isError": result["exit_code"] != 0
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"SSH Error: {result['error']}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"SSH execution failed: {str(e)}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_ssh_sync(host: str, port: int, username: str, password: str, key_filename: str, command: str) -> Dict[str, Any]:
|
||||||
|
"""Synchronous SSH execution (runs in thread pool)."""
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build connection parameters
|
||||||
|
connect_kwargs = {
|
||||||
|
"hostname": host,
|
||||||
|
"port": port,
|
||||||
|
"username": username,
|
||||||
|
"timeout": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use key-based auth if key_filename provided, otherwise use password
|
||||||
|
if key_filename:
|
||||||
|
connect_kwargs["key_filename"] = key_filename
|
||||||
|
else:
|
||||||
|
connect_kwargs["password"] = password
|
||||||
|
|
||||||
|
client.connect(**connect_kwargs)
|
||||||
|
|
||||||
|
stdin, stdout, stderr = client.exec_command(command)
|
||||||
|
|
||||||
|
stdout_text = stdout.read().decode('utf-8')
|
||||||
|
stderr_text = stderr.read().decode('utf-8')
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"stdout": stdout_text,
|
||||||
|
"stderr": stderr_text,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"host": host,
|
||||||
|
"auth_method": "key" if key_filename else "password",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="ssh_file_upload",
|
||||||
|
description="Upload a file to a remote host via SFTP. Returns success status and file paths.",
|
||||||
|
input_schema={
|
||||||
|
"host": str,
|
||||||
|
"username": str,
|
||||||
|
"password": str,
|
||||||
|
"key_filename": str,
|
||||||
|
"local_path": str,
|
||||||
|
"remote_path": str,
|
||||||
|
"port": int,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def ssh_file_upload(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Upload a file to a remote host via SFTP."""
|
||||||
|
if not PARAMIKO_AVAILABLE:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: paramiko not installed. Run: pip install paramiko"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
host = args.get("host")
|
||||||
|
username = args.get("username")
|
||||||
|
password = args.get("password")
|
||||||
|
key_filename = args.get("key_filename")
|
||||||
|
local_path = args.get("local_path")
|
||||||
|
remote_path = args.get("remote_path")
|
||||||
|
port = args.get("port", 22)
|
||||||
|
|
||||||
|
if not all([host, username, local_path, remote_path]):
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: Missing required parameters: host, username, local_path, remote_path"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
if not password and not key_filename:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": "Error: Must provide either password or key_filename for authentication"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
_upload_file_sync,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
key_filename,
|
||||||
|
local_path,
|
||||||
|
remote_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format result as MCP-compliant text content
|
||||||
|
if result["success"]:
|
||||||
|
text = (
|
||||||
|
f"File uploaded successfully via SFTP\n"
|
||||||
|
f"Host: {result['host']}\n"
|
||||||
|
f"Auth: {result['auth_method']}\n"
|
||||||
|
f"Local: {result['local_path']}\n"
|
||||||
|
f"Remote: {result['remote_path']}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"SFTP Error: {result['error']}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": f"SFTP upload failed: {str(e)}"}],
|
||||||
|
"isError": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_file_sync(host: str, port: int, username: str, password: str, key_filename: str, local_path: str, remote_path: str) -> Dict[str, Any]:
|
||||||
|
"""Synchronous SFTP upload (runs in thread pool)."""
|
||||||
|
transport = paramiko.Transport((host, port))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use key-based auth if key_filename provided, otherwise use password
|
||||||
|
if key_filename:
|
||||||
|
private_key = paramiko.RSAKey.from_private_key_file(key_filename)
|
||||||
|
transport.connect(username=username, pkey=private_key)
|
||||||
|
else:
|
||||||
|
transport.connect(username=username, password=password)
|
||||||
|
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
|
||||||
|
sftp.put(local_path, remote_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"local_path": local_path,
|
||||||
|
"remote_path": remote_path,
|
||||||
|
"host": host,
|
||||||
|
"auth_method": "key" if key_filename else "password",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
sftp.close() if 'sftp' in locals() else None
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Create the MCP server
|
||||||
|
ssh_mcp_server = create_sdk_mcp_server(
|
||||||
|
name="ssh",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[ssh_execute, ssh_file_upload],
|
||||||
|
)
|
||||||
1
mcp_servers/obsidian/__init__.py
Normal file
1
mcp_servers/obsidian/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# mcp_servers.obsidian - Obsidian MCP server integration
|
||||||
@@ -456,10 +456,6 @@ class MemorySystem:
|
|||||||
# Normalize to 0-1, then invert (lower BM25 is better)
|
# Normalize to 0-1, then invert (lower BM25 is better)
|
||||||
normalized = (chunk_data["bm25_score"] - min_bm25) / bm25_range
|
normalized = (chunk_data["bm25_score"] - min_bm25) / bm25_range
|
||||||
bm25_map[chunk_id]["normalized_bm25"] = 1 - normalized
|
bm25_map[chunk_id]["normalized_bm25"] = 1 - normalized
|
||||||
else:
|
|
||||||
# No BM25 results
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 5. Combine scores: 0.7 vector + 0.3 BM25
|
# 5. Combine scores: 0.7 vector + 0.3 BM25
|
||||||
combined_scores = {}
|
combined_scores = {}
|
||||||
|
|
||||||
|
|||||||
102
quick_start.bat
102
quick_start.bat
@@ -1,102 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo ============================================================
|
|
||||||
echo Ajarbot Quick Start for Windows 11
|
|
||||||
echo ============================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Check if Python is installed
|
|
||||||
python --version >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Python is not installed or not in PATH
|
|
||||||
echo Please install Python from https://www.python.org/downloads/
|
|
||||||
echo Make sure to check "Add Python to PATH" during installation
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [1/5] Python detected
|
|
||||||
python --version
|
|
||||||
|
|
||||||
REM Check if virtual environment exists
|
|
||||||
if not exist "venv\" (
|
|
||||||
echo.
|
|
||||||
echo [2/5] Creating virtual environment...
|
|
||||||
python -m venv venv
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Failed to create virtual environment
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo Virtual environment created
|
|
||||||
) else (
|
|
||||||
echo.
|
|
||||||
echo [2/5] Virtual environment already exists
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Activate virtual environment
|
|
||||||
echo.
|
|
||||||
echo [3/5] Activating virtual environment...
|
|
||||||
call venv\Scripts\activate.bat
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Failed to activate virtual environment
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Install dependencies
|
|
||||||
echo.
|
|
||||||
echo [4/5] Installing dependencies...
|
|
||||||
pip install -r requirements.txt --quiet
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo [ERROR] Failed to install dependencies
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo Dependencies installed
|
|
||||||
|
|
||||||
REM Check for API key
|
|
||||||
echo.
|
|
||||||
echo [5/5] Checking for API key...
|
|
||||||
if "%ANTHROPIC_API_KEY%"=="" (
|
|
||||||
echo.
|
|
||||||
echo [WARNING] ANTHROPIC_API_KEY not set
|
|
||||||
echo.
|
|
||||||
echo Please set your API key using one of these methods:
|
|
||||||
echo.
|
|
||||||
echo Option 1: Set for current session only
|
|
||||||
echo set ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|
||||||
echo.
|
|
||||||
echo Option 2: Add to system environment variables
|
|
||||||
echo Win + X -^> System -^> Advanced -^> Environment Variables
|
|
||||||
echo.
|
|
||||||
echo Option 3: Create .env file
|
|
||||||
echo echo ANTHROPIC_API_KEY=sk-ant-your-key-here ^> .env
|
|
||||||
echo pip install python-dotenv
|
|
||||||
echo.
|
|
||||||
set /p API_KEY="Enter your Anthropic API key (or press Enter to skip): "
|
|
||||||
if not "!API_KEY!"=="" (
|
|
||||||
set ANTHROPIC_API_KEY=!API_KEY!
|
|
||||||
echo API key set for this session
|
|
||||||
) else (
|
|
||||||
echo Skipping API key setup
|
|
||||||
echo You'll need to set it before running examples
|
|
||||||
)
|
|
||||||
) else (
|
|
||||||
echo API key found
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ============================================================
|
|
||||||
echo Setup Complete!
|
|
||||||
echo ============================================================
|
|
||||||
echo.
|
|
||||||
echo Your environment is ready. Try these commands:
|
|
||||||
echo.
|
|
||||||
echo python example_usage.py # Basic agent test
|
|
||||||
echo python example_bot_with_pulse_brain.py # Pulse ^& Brain monitoring
|
|
||||||
echo python example_bot_with_scheduler.py # Task scheduler
|
|
||||||
echo python bot_runner.py --init # Generate adapter config
|
|
||||||
echo.
|
|
||||||
echo For more information, see docs\WINDOWS_DEPLOYMENT.md
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@@ -32,3 +32,6 @@ python-dotenv>=1.0.0
|
|||||||
# Web fetching dependencies
|
# Web fetching dependencies
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
|
|
||||||
|
# SSH dependencies
|
||||||
|
paramiko>=3.4.0
|
||||||
|
|||||||
@@ -431,26 +431,6 @@ class TaskScheduler:
|
|||||||
return f"Task '{task_name}' executed"
|
return f"Task '{task_name}' executed"
|
||||||
|
|
||||||
|
|
||||||
def integrate_scheduler_with_runtime(
|
|
||||||
runtime: Any,
|
|
||||||
agent: Agent,
|
|
||||||
config_file: Optional[str] = None,
|
|
||||||
) -> TaskScheduler:
|
|
||||||
"""
|
|
||||||
Integrate scheduled tasks with the bot runtime.
|
|
||||||
|
|
||||||
Usage in bot_runner.py:
|
|
||||||
scheduler = integrate_scheduler_with_runtime(runtime, agent)
|
|
||||||
scheduler.start()
|
|
||||||
"""
|
|
||||||
scheduler = TaskScheduler(agent, config_file)
|
|
||||||
|
|
||||||
for adapter in runtime.registry.get_all():
|
|
||||||
scheduler.add_adapter(adapter.platform_name, adapter)
|
|
||||||
|
|
||||||
return scheduler
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
provider="claude", workspace_dir="./memory_workspace"
|
provider="claude", workspace_dir="./memory_workspace"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,416 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
=== 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
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -128,7 +128,7 @@ class SelfHealingSystem:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.memory.write_memory(log_entry, daily=False)
|
self.memory.write_memory(log_entry, daily=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Last resort: print to console if memory write fails
|
# Last resort: print to console if memory write fails
|
||||||
print(f"[SelfHealing] Failed to write error log to MEMORY.md: {e}")
|
print(f"[SelfHealing] Failed to write error log to MEMORY.md: {e}")
|
||||||
|
|||||||
94
tools.py
94
tools.py
@@ -190,6 +190,33 @@ TOOL_DEFINITIONS = [
|
|||||||
"required": ["message_id"],
|
"required": ["message_id"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "download_attachment",
|
||||||
|
"description": "Download an email attachment from Gmail. Use get_email first to get attachment IDs.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Gmail message ID containing the attachment",
|
||||||
|
},
|
||||||
|
"attachment_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Attachment ID from the email (obtained from get_email)",
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Original filename of the attachment",
|
||||||
|
},
|
||||||
|
"output_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory to save the file (default: 'downloads')",
|
||||||
|
"default": "downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["message_id", "attachment_id", "filename"],
|
||||||
|
},
|
||||||
|
},
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
{
|
{
|
||||||
"name": "read_calendar",
|
"name": "read_calendar",
|
||||||
@@ -411,6 +438,13 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
message_id=tool_input["message_id"],
|
message_id=tool_input["message_id"],
|
||||||
format_type=tool_input.get("format", "text"),
|
format_type=tool_input.get("format", "text"),
|
||||||
)
|
)
|
||||||
|
elif tool_name == "download_attachment":
|
||||||
|
result_str = _download_attachment(
|
||||||
|
message_id=tool_input["message_id"],
|
||||||
|
attachment_id=tool_input["attachment_id"],
|
||||||
|
filename=tool_input["filename"],
|
||||||
|
output_dir=tool_input.get("output_dir", "downloads"),
|
||||||
|
)
|
||||||
elif tool_name == "read_calendar":
|
elif tool_name == "read_calendar":
|
||||||
result_str = _read_calendar(
|
result_str = _read_calendar(
|
||||||
days_ahead=tool_input.get("days_ahead", 7),
|
days_ahead=tool_input.get("days_ahead", 7),
|
||||||
@@ -505,25 +539,6 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
|||||||
return f"Error executing {tool_name}: {error_msg}"
|
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(
|
def _execute_obsidian_tool(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
tool_input: Dict[str, Any],
|
tool_input: Dict[str, Any],
|
||||||
@@ -532,7 +547,7 @@ def _execute_obsidian_tool(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Execute an Obsidian MCP tool with fallback to custom tools."""
|
"""Execute an Obsidian MCP tool with fallback to custom tools."""
|
||||||
try:
|
try:
|
||||||
from obsidian_mcp import (
|
from mcp_servers.obsidian.obsidian_mcp import (
|
||||||
check_obsidian_health,
|
check_obsidian_health,
|
||||||
should_fallback_to_custom,
|
should_fallback_to_custom,
|
||||||
)
|
)
|
||||||
@@ -557,7 +572,7 @@ def _execute_obsidian_tool(
|
|||||||
f"Please start Obsidian desktop app."
|
f"Please start Obsidian desktop app."
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return f"Error: obsidian_mcp module not found for tool '{tool_name}'"
|
return f"Error: mcp_servers.obsidian.obsidian_mcp module not found for tool '{tool_name}'"
|
||||||
|
|
||||||
|
|
||||||
# Maximum characters of tool output to return (prevents token explosion)
|
# Maximum characters of tool output to return (prevents token explosion)
|
||||||
@@ -844,7 +859,10 @@ def _get_email(message_id: str, format_type: str = "text") -> str:
|
|||||||
output.append(f"\n{email_data.get('body', '')}")
|
output.append(f"\n{email_data.get('body', '')}")
|
||||||
|
|
||||||
if email_data.get("attachments"):
|
if email_data.get("attachments"):
|
||||||
output.append(f"\nAttachments: {', '.join(email_data['attachments'])}")
|
output.append("\nAttachments:")
|
||||||
|
for att in email_data["attachments"]:
|
||||||
|
att_info = f" - {att['filename']} ({att.get('size', 0)} bytes, ID: {att.get('attachment_id', 'N/A')})"
|
||||||
|
output.append(att_info)
|
||||||
|
|
||||||
full_output = "\n".join(output)
|
full_output = "\n".join(output)
|
||||||
if len(full_output) > _MAX_TOOL_OUTPUT:
|
if len(full_output) > _MAX_TOOL_OUTPUT:
|
||||||
@@ -855,6 +873,36 @@ def _get_email(message_id: str, format_type: str = "text") -> str:
|
|||||||
return f"Error getting email: {result.get('error', 'Unknown error')}"
|
return f"Error getting email: {result.get('error', 'Unknown error')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _download_attachment(
|
||||||
|
message_id: str,
|
||||||
|
attachment_id: str,
|
||||||
|
filename: str,
|
||||||
|
output_dir: str = "downloads",
|
||||||
|
) -> str:
|
||||||
|
"""Download an email attachment."""
|
||||||
|
gmail_client, _, _ = _initialize_google_clients()
|
||||||
|
|
||||||
|
if not gmail_client:
|
||||||
|
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
|
||||||
|
|
||||||
|
result = gmail_client.download_attachment(
|
||||||
|
message_id=message_id,
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
filename=filename,
|
||||||
|
output_dir=output_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return (
|
||||||
|
f"Downloaded attachment successfully:\n"
|
||||||
|
f" File: {result['filename']}\n"
|
||||||
|
f" Path: {result['file_path']}\n"
|
||||||
|
f" Size: {result['size']:,} bytes"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return f"Error downloading attachment: {result.get('error', 'Unknown error')}"
|
||||||
|
|
||||||
|
|
||||||
def _read_calendar(
|
def _read_calendar(
|
||||||
days_ahead: int = 7,
|
days_ahead: int = 7,
|
||||||
calendar_id: str = "primary",
|
calendar_id: str = "primary",
|
||||||
@@ -1029,8 +1077,6 @@ def _obsidian_fallback(tool_name: str, tool_input: Dict[str, Any]) -> Optional[s
|
|||||||
|
|
||||||
Returns None if no fallback is possible for the given tool.
|
Returns None if no fallback is possible for the given tool.
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
if tool_name == "obsidian_read_note":
|
if tool_name == "obsidian_read_note":
|
||||||
# Map to read_file with vault-relative path
|
# Map to read_file with vault-relative path
|
||||||
vault_path = Path("memory_workspace/obsidian")
|
vault_path = Path("memory_workspace/obsidian")
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ _PRICING = {
|
|||||||
"cache_write": 3.75, # Cache creation
|
"cache_write": 3.75, # Cache creation
|
||||||
"cache_read": 0.30, # 90% discount on cache hits
|
"cache_read": 0.30, # 90% discount on cache hits
|
||||||
},
|
},
|
||||||
|
"claude-sonnet-4-6": {
|
||||||
|
"input": 3.00,
|
||||||
|
"output": 15.00,
|
||||||
|
"cache_write": 3.75,
|
||||||
|
"cache_read": 0.30,
|
||||||
|
},
|
||||||
"claude-opus-4-6": {
|
"claude-opus-4-6": {
|
||||||
"input": 15.00,
|
"input": 15.00,
|
||||||
"output": 75.00,
|
"output": 75.00,
|
||||||
|
|||||||
Reference in New Issue
Block a user