Compare commits
7 Commits
0eb5d2cab4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fe7c146dc6 | |||
| 0271dea551 | |||
| 50cf7165cb | |||
| 911d362ba2 | |||
| ce2c384387 | |||
| a8665d8c72 | |||
| f018800d94 |
78
.env.example
78
.env.example
@@ -1,8 +1,78 @@
|
||||
# Environment Variables (EXAMPLE)
|
||||
# Copy this to .env and add your actual API keys
|
||||
# ========================================
|
||||
# Ajarbot Environment Configuration
|
||||
# ========================================
|
||||
# Copy this file to .env and configure for your setup
|
||||
|
||||
# Anthropic API Key - Get from https://console.anthropic.com/settings/keys
|
||||
# ========================================
|
||||
# LLM Configuration
|
||||
# ========================================
|
||||
|
||||
# LLM Mode - Choose how to access Claude
|
||||
# Options:
|
||||
# - "agent-sdk" (default) - Use Claude Pro subscription via Agent SDK
|
||||
# - "api" - Use pay-per-token API (requires ANTHROPIC_API_KEY)
|
||||
#
|
||||
# Agent SDK mode pros: Unlimited usage within Pro limits, no API key needed
|
||||
# API mode pros: Works in any environment, predictable costs, better for production
|
||||
AJARBOT_LLM_MODE=agent-sdk
|
||||
|
||||
# Anthropic API Key - ONLY required for "api" mode
|
||||
# Get your key from: https://console.anthropic.com/settings/keys
|
||||
# For agent-sdk mode, authenticate with: claude auth login
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
|
||||
# Optional: GLM API Key (if using GLM provider)
|
||||
# ========================================
|
||||
# Messaging Platform Adapters
|
||||
# ========================================
|
||||
# Adapter credentials can also be stored in config/adapters.local.yaml
|
||||
|
||||
# Slack
|
||||
# Get tokens from: https://api.slack.com/apps
|
||||
AJARBOT_SLACK_BOT_TOKEN=xoxb-your-bot-token
|
||||
AJARBOT_SLACK_APP_TOKEN=xapp-your-app-token
|
||||
|
||||
# Telegram
|
||||
# Get token from: https://t.me/BotFather
|
||||
AJARBOT_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
|
||||
|
||||
# ========================================
|
||||
# Obsidian MCP Integration (Optional)
|
||||
# ========================================
|
||||
# Obsidian MCP server provides advanced vault operations via Obsidian REST API
|
||||
# See: OBSIDIAN_MCP_INTEGRATION.md for setup instructions
|
||||
|
||||
# Enable/disable Obsidian MCP integration
|
||||
OBSIDIAN_MCP_ENABLED=false
|
||||
|
||||
# Obsidian Local REST API Key
|
||||
# Install "Local REST API" plugin in Obsidian first, then generate key in settings
|
||||
OBSIDIAN_API_KEY=your-obsidian-api-key-here
|
||||
|
||||
# Obsidian REST API endpoint (default: http://127.0.0.1:27123)
|
||||
OBSIDIAN_BASE_URL=http://127.0.0.1:27123
|
||||
|
||||
# Path to your main Obsidian vault (overrides config/obsidian_mcp.yaml)
|
||||
# OBSIDIAN_VAULT_PATH=C:/Users/YourName/Documents/MyVault
|
||||
|
||||
# Tool routing strategy (optional, overrides config/obsidian_mcp.yaml)
|
||||
# Options: obsidian_preferred, custom_preferred, obsidian_only
|
||||
# OBSIDIAN_ROUTING_STRATEGY=obsidian_preferred
|
||||
|
||||
# ========================================
|
||||
# Alternative LLM Providers (Optional)
|
||||
# ========================================
|
||||
|
||||
# GLM (z.ai) - Optional alternative to Claude
|
||||
# GLM_API_KEY=your-glm-key-here
|
||||
|
||||
# ========================================
|
||||
# Legacy/Deprecated Settings
|
||||
# ========================================
|
||||
# The following settings are deprecated and no longer needed:
|
||||
#
|
||||
# USE_CLAUDE_CODE_SERVER=true
|
||||
# CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
# USE_AGENT_SDK=true
|
||||
# USE_DIRECT_API=true
|
||||
#
|
||||
# Use AJARBOT_LLM_MODE instead (see above)
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -42,6 +42,8 @@ Thumbs.db
|
||||
*.local.json
|
||||
.env
|
||||
.env.local
|
||||
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
||||
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
||||
|
||||
# Memory workspace (optional - remove if you want to version control)
|
||||
@@ -49,6 +51,9 @@ memory_workspace/memory/*.md
|
||||
memory_workspace/memory_index.db
|
||||
memory_workspace/users/*.md # User profiles (jordan.md, etc.)
|
||||
memory_workspace/vectors.usearch
|
||||
memory_workspace/obsidian/ # Zettelkasten vault (personal notes)
|
||||
memory_workspace/SOUL.md # Personal config (use SOUL.example.md)
|
||||
memory_workspace/MEMORY.md # Personal memory (use MEMORY.example.md)
|
||||
|
||||
# User profiles (personal info)
|
||||
users/
|
||||
@@ -60,5 +65,12 @@ usage_data.json
|
||||
config/google_credentials.yaml
|
||||
config/google_oauth_token.json
|
||||
|
||||
# Obsidian MCP config (contains vault path - use obsidian_mcp.example.yaml)
|
||||
config/obsidian_mcp.yaml
|
||||
|
||||
# Gitea config (contains access token - use gitea_config.example.yaml)
|
||||
config/gitea_config.yaml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
256
CLAUDE_CODE_SETUP.md
Normal file
256
CLAUDE_CODE_SETUP.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# Claude Agent SDK Setup
|
||||
|
||||
Use your **Claude Pro subscription** OR **API key** with ajarbot - no separate server needed.
|
||||
|
||||
## What is the Agent SDK?
|
||||
|
||||
The Claude Agent SDK lets you use Claude directly from Python using either:
|
||||
- **Your Pro subscription** (unlimited usage within Pro limits)
|
||||
- **Your API key** (pay-per-token)
|
||||
|
||||
The SDK automatically handles authentication and runs Claude in-process - no FastAPI server required.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This installs `claude-agent-sdk` along with all other dependencies.
|
||||
|
||||
### 2. Choose Your Mode
|
||||
|
||||
Set `AJARBOT_LLM_MODE` in your `.env` file (or leave it unset for default):
|
||||
|
||||
```bash
|
||||
# Use Claude Pro subscription (default - recommended for personal use)
|
||||
AJARBOT_LLM_MODE=agent-sdk
|
||||
|
||||
# OR use pay-per-token API
|
||||
AJARBOT_LLM_MODE=api
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
### 3. Authenticate (Agent SDK mode only)
|
||||
|
||||
If using `agent-sdk` mode, authenticate with Claude CLI:
|
||||
|
||||
```bash
|
||||
# Install Claude CLI (if not already installed)
|
||||
# Download from: https://claude.ai/download
|
||||
|
||||
# Login with your Claude account
|
||||
claude auth login
|
||||
```
|
||||
|
||||
This opens a browser window to authenticate with your claude.ai account.
|
||||
|
||||
### 4. Run Your Bot
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
run.bat
|
||||
```
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
python ajarbot.py
|
||||
```
|
||||
|
||||
That's it! No separate server to manage.
|
||||
|
||||
## Architecture Comparison
|
||||
|
||||
### Old Setup (Deprecated)
|
||||
```
|
||||
Telegram/Slack → ajarbot → FastAPI Server (localhost:8000) → Claude Code SDK → Claude
|
||||
```
|
||||
|
||||
### New Setup (Current)
|
||||
```
|
||||
Telegram/Slack → ajarbot → Claude Agent SDK → Claude (Pro OR API)
|
||||
```
|
||||
|
||||
The new setup eliminates the FastAPI server, reducing complexity and removing an extra process.
|
||||
|
||||
## Mode Details
|
||||
|
||||
### Agent SDK Mode (Default)
|
||||
|
||||
**Pros:**
|
||||
- Uses Pro subscription (unlimited within Pro limits)
|
||||
- No API key needed
|
||||
- Higher context window (200K tokens)
|
||||
- Simple authentication via Claude CLI
|
||||
|
||||
**Cons:**
|
||||
- Requires Node.js and Claude CLI installed
|
||||
- Subject to Pro subscription rate limits
|
||||
- Not suitable for multi-user production
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# .env file
|
||||
AJARBOT_LLM_MODE=agent-sdk
|
||||
|
||||
# Authenticate once
|
||||
claude auth login
|
||||
```
|
||||
|
||||
### API Mode
|
||||
|
||||
**Pros:**
|
||||
- No CLI authentication needed
|
||||
- Predictable pay-per-token pricing
|
||||
- Works in any environment (no Node.js required)
|
||||
- Better for production/multi-user scenarios
|
||||
|
||||
**Cons:**
|
||||
- Costs money per API call
|
||||
- Requires managing API keys
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# .env file
|
||||
AJARBOT_LLM_MODE=api
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## Cost Comparison
|
||||
|
||||
| Mode | Cost Model | Best For |
|
||||
|------|-----------|----------|
|
||||
| **Agent SDK (Pro)** | $20/month flat rate | Heavy personal usage |
|
||||
| **API (pay-per-token)** | ~$0.25-$3 per 1M tokens | Light usage, production |
|
||||
|
||||
With default Haiku model, API mode costs approximately:
|
||||
- ~$0.04/day for moderate personal use (1000 messages/month)
|
||||
- ~$1.20/month for light usage
|
||||
|
||||
## Pre-Flight Checks
|
||||
|
||||
The `ajarbot.py` launcher runs automatic checks before starting:
|
||||
|
||||
**Agent SDK mode checks:**
|
||||
- Python 3.10+
|
||||
- Node.js available
|
||||
- Claude CLI authenticated
|
||||
- Config file exists
|
||||
|
||||
**API mode checks:**
|
||||
- Python 3.10+
|
||||
- `.env` file exists
|
||||
- `ANTHROPIC_API_KEY` is set
|
||||
- Config file exists
|
||||
|
||||
Run health check manually:
|
||||
```bash
|
||||
python ajarbot.py --health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Node.js not found"
|
||||
Agent SDK mode requires Node.js. Either:
|
||||
1. Install Node.js from https://nodejs.org
|
||||
2. Switch to API mode (set `AJARBOT_LLM_MODE=api`)
|
||||
|
||||
### "Claude CLI not authenticated"
|
||||
```bash
|
||||
# Check authentication status
|
||||
claude auth status
|
||||
|
||||
# Re-authenticate
|
||||
claude auth logout
|
||||
claude auth login
|
||||
```
|
||||
|
||||
### "Agent SDK not available"
|
||||
```bash
|
||||
pip install claude-agent-sdk
|
||||
```
|
||||
|
||||
If installation fails, switch to API mode.
|
||||
|
||||
### Rate Limits (Agent SDK mode)
|
||||
|
||||
If you hit Pro subscription limits:
|
||||
- Wait for limit refresh (usually 24 hours)
|
||||
- Switch to API mode temporarily:
|
||||
```bash
|
||||
# In .env
|
||||
AJARBOT_LLM_MODE=api
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
### "ANTHROPIC_API_KEY not set" (API mode)
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env and add your API key
|
||||
```
|
||||
|
||||
Get your API key from: https://console.anthropic.com/settings/keys
|
||||
|
||||
## Migration from Old Setup
|
||||
|
||||
If you previously used the FastAPI server (`claude_code_server.py`):
|
||||
|
||||
1. **Remove old environment variables:**
|
||||
```bash
|
||||
# Delete these from .env
|
||||
USE_CLAUDE_CODE_SERVER=true
|
||||
CLAUDE_CODE_SERVER_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
2. **Set new mode:**
|
||||
```bash
|
||||
# Add to .env
|
||||
AJARBOT_LLM_MODE=agent-sdk # or "api"
|
||||
```
|
||||
|
||||
3. **Stop the old server** (no longer needed):
|
||||
- The `claude_code_server.py` process can be stopped
|
||||
- It's no longer used
|
||||
|
||||
4. **Run with new launcher:**
|
||||
```bash
|
||||
run.bat # Windows
|
||||
python ajarbot.py # Linux/Mac
|
||||
```
|
||||
|
||||
See [MIGRATION.md](MIGRATION.md) for detailed migration guide.
|
||||
|
||||
## Features
|
||||
|
||||
All ajarbot features work in both modes:
|
||||
- 15 tools (file ops, system commands, Gmail, Calendar, Contacts)
|
||||
- Multi-platform adapters (Slack, Telegram)
|
||||
- Memory system with hybrid search
|
||||
- Task scheduling
|
||||
- Google integration (Gmail, Calendar, Contacts)
|
||||
- Usage tracking (API mode only)
|
||||
|
||||
## Security
|
||||
|
||||
**Agent SDK mode:**
|
||||
- Uses your Claude.ai authentication
|
||||
- No API keys to manage
|
||||
- Credentials stored by Claude CLI (secure)
|
||||
- Runs entirely on localhost
|
||||
|
||||
**API mode:**
|
||||
- API key in `.env` file (gitignored)
|
||||
- Environment variable isolation
|
||||
- No data leaves your machine except to Claude's API
|
||||
|
||||
Both modes are suitable for personal bots. API mode is recommended for production/multi-user scenarios.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Claude Agent SDK GitHub](https://github.com/anthropics/anthropic-sdk-python)
|
||||
- [Claude CLI Download](https://claude.ai/download)
|
||||
- [Anthropic API Documentation](https://docs.anthropic.com/)
|
||||
@@ -1,151 +0,0 @@
|
||||
# Hybrid Search Implementation Summary
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
Successfully upgraded Ajarbot's memory system from keyword-only search to **hybrid semantic + keyword search**.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Stack
|
||||
- **FastEmbed** (sentence-transformers/all-MiniLM-L6-v2) - 384-dimensional embeddings
|
||||
- **usearch** - Fast vector similarity search
|
||||
- **SQLite FTS5** - Keyword/BM25 search (retained)
|
||||
|
||||
### Scoring Algorithm
|
||||
- **0.7 weight** - Vector similarity (semantic understanding)
|
||||
- **0.3 weight** - BM25 score (keyword matching)
|
||||
- Combined and normalized for optimal results
|
||||
|
||||
### Performance
|
||||
- **Query time**: ~15ms average (was 5ms keyword-only)
|
||||
- **Storage overhead**: +1.5KB per memory chunk
|
||||
- **Cost**: $0 (runs locally, no API calls)
|
||||
- **Embeddings generated**: 59 for existing memories
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **memory_system.py**
|
||||
- Added FastEmbed and usearch imports
|
||||
- Initialize embedding model in `__init__` (line ~88)
|
||||
- Added `_generate_embedding()` method
|
||||
- Modified `index_file()` to generate and store embeddings
|
||||
- Implemented `search_hybrid()` method
|
||||
- Added database migration for `vector_id` column
|
||||
- Save vector index on `close()`
|
||||
|
||||
2. **agent.py**
|
||||
- Line 71: Changed `search()` to `search_hybrid()`
|
||||
|
||||
3. **memory_workspace/MEMORY.md**
|
||||
- Updated Core Stack section
|
||||
- Changed "Planned (Phase 2)" to "IMPLEMENTED"
|
||||
- Added Recent Changes entry
|
||||
- Updated Architecture Decisions
|
||||
|
||||
## Results - Before vs After
|
||||
|
||||
### Example Query: "How do I reduce costs?"
|
||||
|
||||
**Keyword Search (old)**:
|
||||
```
|
||||
No results found!
|
||||
```
|
||||
|
||||
**Hybrid Search (new)**:
|
||||
```
|
||||
1. MEMORY.md:28 (score: 0.228)
|
||||
## Cost Optimizations (2026-02-13)
|
||||
Target: Minimize API costs...
|
||||
|
||||
2. SOUL.md:45 (score: 0.213)
|
||||
Be proactive and use tools...
|
||||
```
|
||||
|
||||
### Example Query: "when was I born"
|
||||
|
||||
**Keyword Search (old)**:
|
||||
```
|
||||
No results found!
|
||||
```
|
||||
|
||||
**Hybrid Search (new)**:
|
||||
```
|
||||
1. SOUL.md:1 (score: 0.071)
|
||||
# SOUL - Agent Identity...
|
||||
|
||||
2. MEMORY.md:49 (score: 0.060)
|
||||
## Search Evolution...
|
||||
```
|
||||
|
||||
## How It Works Automatically
|
||||
|
||||
The bot now automatically uses hybrid search on **every chat message**:
|
||||
|
||||
1. User sends message to bot
|
||||
2. `agent.py` calls `memory.search_hybrid(user_message, max_results=2)`
|
||||
3. System generates embedding for query (~10ms)
|
||||
4. Searches vector index for semantic matches
|
||||
5. Searches FTS5 for keyword matches
|
||||
6. Combines scores (70% semantic, 30% keyword)
|
||||
7. Returns top 2 results
|
||||
8. Results injected into LLM context automatically
|
||||
|
||||
**No user action needed** - it's completely transparent!
|
||||
|
||||
## Dependencies Added
|
||||
|
||||
```bash
|
||||
pip install fastembed usearch
|
||||
```
|
||||
|
||||
Installs:
|
||||
- fastembed (0.7.4)
|
||||
- usearch (2.23.0)
|
||||
- numpy (2.4.2)
|
||||
- onnxruntime (1.24.1)
|
||||
- Plus supporting libraries
|
||||
|
||||
## Files Created
|
||||
|
||||
- `memory_workspace/vectors.usearch` - Vector index (~90KB for 59 vectors)
|
||||
- `test_hybrid_search.py` - Test script
|
||||
- `test_agent_hybrid.py` - Agent integration test
|
||||
- `demo_hybrid_comparison.py` - Comparison demo
|
||||
|
||||
## Memory Impact
|
||||
|
||||
- **FastEmbed model**: ~50MB RAM (loaded once, persists)
|
||||
- **Vector index**: ~1.5KB per memory chunk
|
||||
- **59 memories**: ~90KB total vector storage
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **10x better semantic recall** - Finds memories by meaning, not just keywords
|
||||
2. **Natural language queries** - "How do I save money?" finds cost optimization
|
||||
3. **Zero cost** - No API calls, runs entirely locally
|
||||
4. **Fast** - Sub-20ms queries
|
||||
5. **Automatic** - Works transparently in all bot interactions
|
||||
6. **Maintains keyword power** - Still finds exact technical terms
|
||||
|
||||
## Next Steps (Optional Future Enhancements)
|
||||
|
||||
- Add `search_user_hybrid()` for per-user semantic search
|
||||
- Tune weights (currently 0.7/0.3) based on query patterns
|
||||
- Add query expansion for better recall
|
||||
- Pre-compute common query embeddings for speed
|
||||
|
||||
## Verification
|
||||
|
||||
Run comparison test:
|
||||
```bash
|
||||
python demo_hybrid_comparison.py
|
||||
```
|
||||
|
||||
Output shows keyword search finding 0 results, hybrid finding relevant matches for all queries.
|
||||
|
||||
---
|
||||
|
||||
**Implementation Status**: ✅ COMPLETE
|
||||
**Date**: 2026-02-13
|
||||
**Lines of Code**: ~150 added to memory_system.py
|
||||
**Breaking Changes**: None (backward compatible)
|
||||
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
1304
JARVIS_VOICE_INTEGRATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
207
LOGGING.md
Normal file
207
LOGGING.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 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
Normal file
152
MCP_MIGRATION.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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`
|
||||
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
103
OBSIDIAN_MCP_SETUP_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Quick Setup: Obsidian Local REST API Plugin
|
||||
|
||||
## Your Current Status
|
||||
- ✅ Obsidian is running
|
||||
- ✅ Config file is ready (`config/obsidian_mcp.yaml`)
|
||||
- ❌ Local REST API plugin not responding on port 27123
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Install the Local REST API Plugin in Obsidian
|
||||
|
||||
1. Open **Obsidian**
|
||||
2. Go to **Settings** (gear icon) → **Community Plugins**
|
||||
3. If you see "Safe mode is on", click **Turn off Safe Mode**
|
||||
4. Click **Browse** button
|
||||
5. Search for: **"Local REST API"**
|
||||
6. Click **Install** on the "Local REST API" plugin by coddingtonbear
|
||||
7. After installation, click **Enable**
|
||||
|
||||
### 2. Configure the Plugin
|
||||
|
||||
1. In Obsidian Settings, scroll down to **Plugin Options**
|
||||
2. Find **Local REST API** in the left sidebar
|
||||
3. Copy your API key shown in the plugin settings
|
||||
4. Compare it with the key in your `config/obsidian_mcp.yaml`:
|
||||
```
|
||||
api_key: "ee625f06a778e3267a9219f9b8c1065a039375ea270e414a34436c6a3027f2da"
|
||||
```
|
||||
5. If they don't match, update the config file with the correct key
|
||||
|
||||
### 3. Verify the Plugin is Running
|
||||
|
||||
1. Check that the plugin shows as **enabled** in Obsidian
|
||||
2. The plugin should show: "Server running on http://127.0.0.1:27123"
|
||||
3. Restart Obsidian if needed
|
||||
|
||||
### 4. Test the Connection
|
||||
|
||||
Run this command in your project directory:
|
||||
|
||||
```powershell
|
||||
python -c "from obsidian_mcp import check_obsidian_health; print('Health Check:', check_obsidian_health(force=True))"
|
||||
```
|
||||
|
||||
**Expected output**: `Health Check: True`
|
||||
|
||||
### 5. Restart the Bot
|
||||
|
||||
```powershell
|
||||
venv\Scripts\activate
|
||||
python bot_runner.py
|
||||
```
|
||||
|
||||
Look for this line in the startup logs:
|
||||
```
|
||||
[LLM] Obsidian MCP server registered (8 tools)
|
||||
```
|
||||
|
||||
If you see this instead, the plugin isn't working yet:
|
||||
```
|
||||
[LLM] Obsidian MCP enabled but health check failed - using custom tools only
|
||||
```
|
||||
|
||||
## Alternative: File-Based Access (Already Working)
|
||||
|
||||
If you don't want to use the Local REST API plugin, your bot can **already** access your Obsidian vault via the filesystem using these tools:
|
||||
|
||||
- `fleeting_note` - Quick capture with auto-ID
|
||||
- `daily_note` - Timestamped journal entries
|
||||
- `literature_note` - Save web articles
|
||||
- `permanent_note` - Create refined notes with auto-linking
|
||||
- `search_vault` - Hybrid semantic search
|
||||
- `search_by_tags` - Find notes by tags
|
||||
- `read_file` / `write_file` / `edit_file` - Direct file access
|
||||
|
||||
The **Obsidian MCP tools** add these extra capabilities:
|
||||
- `obsidian_update_note` - Frontmatter-aware editing
|
||||
- `obsidian_global_search` - Native Obsidian search
|
||||
- `obsidian_manage_frontmatter` - Advanced metadata management
|
||||
- `obsidian_manage_tags` - Bulk tag operations
|
||||
- `obsidian_delete_note` - Safe deletion
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin shows "Server not running"
|
||||
- Click the **Restart Server** button in the plugin settings
|
||||
- Check Windows Firewall isn't blocking port 27123
|
||||
|
||||
### API key mismatch
|
||||
- Copy the EXACT key from Obsidian plugin settings
|
||||
- Update `config/obsidian_mcp.yaml` → `connection.api_key`
|
||||
|
||||
### Wrong vault path
|
||||
- Your current vault path: `C:/Users/fam1n/OneDrive/Documents/Remote-Mind-Vault`
|
||||
- Verify this path exists and contains a `.obsidian` folder
|
||||
|
||||
### Health check still fails after setup
|
||||
- Restart Obsidian
|
||||
- Restart the bot
|
||||
- Check port 27123 isn't used by another program:
|
||||
```powershell
|
||||
netstat -ano | findstr :27123
|
||||
```
|
||||
137
QUICK_REFERENCE_AGENT_SDK.md
Normal file
137
QUICK_REFERENCE_AGENT_SDK.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 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
|
||||
53
README.md
53
README.md
@@ -15,19 +15,18 @@ A lightweight, cost-effective AI agent framework for building proactive bots wit
|
||||
|
||||
## Features
|
||||
|
||||
- **Flexible Claude Integration**: Use Pro subscription OR pay-per-token API via Agent SDK (no server needed)
|
||||
- **Cost-Optimized AI**: Default Haiku 4.5 model (12x cheaper), auto-caching on Sonnet (90% savings), dynamic model switching
|
||||
- **Smart Memory System**: SQLite-based memory with automatic context retrieval and FTS search
|
||||
- **Smart Memory System**: SQLite-based memory with automatic context retrieval and hybrid vector search
|
||||
- **Multi-Platform Adapters**: Run on Slack, Telegram, and more simultaneously
|
||||
- **15 Integrated Tools**: File ops, shell commands, Gmail, Google Calendar, Contacts
|
||||
- **Pulse & Brain Monitoring**: 92% cost savings with intelligent conditional monitoring (recommended)
|
||||
- **Task Scheduling**: Cron-like scheduled tasks with flexible cadences
|
||||
- **Tool Use System**: File operations, command execution, and autonomous task completion
|
||||
- **Multi-LLM Support**: Claude (Anthropic) primary, GLM (z.ai) optional
|
||||
|
||||
## Quick Start
|
||||
|
||||
**For detailed setup instructions**, see **[SETUP.md](SETUP.md)** - includes API key setup, configuration, and troubleshooting.
|
||||
|
||||
### 30-Second Quickstart
|
||||
### Option 1: Agent SDK (Recommended - Uses Pro Subscription)
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
@@ -35,18 +34,39 @@ git clone https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
||||
cd ajarbot
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure (copy examples and add your API key)
|
||||
cp .env.example .env
|
||||
cp config/scheduled_tasks.example.yaml config/scheduled_tasks.yaml
|
||||
# Authenticate with Claude CLI (one-time setup)
|
||||
claude auth login
|
||||
|
||||
# Add your Anthropic API key to .env
|
||||
# Configure adapters
|
||||
cp .env.example .env
|
||||
cp config/adapters.example.yaml config/adapters.local.yaml
|
||||
# Edit config/adapters.local.yaml with your Slack/Telegram tokens
|
||||
|
||||
# Run
|
||||
run.bat # Windows
|
||||
python ajarbot.py # Linux/Mac
|
||||
```
|
||||
|
||||
### Option 2: API Mode (Pay-per-token)
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
||||
cd ajarbot
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure
|
||||
cp .env.example .env
|
||||
# Edit .env and add:
|
||||
# AJARBOT_LLM_MODE=api
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# Run
|
||||
python example_usage.py
|
||||
run.bat # Windows
|
||||
python ajarbot.py # Linux/Mac
|
||||
```
|
||||
|
||||
**Windows users**: Run `quick_start.bat` for automated setup
|
||||
**See [CLAUDE_CODE_SETUP.md](CLAUDE_CODE_SETUP.md)** for detailed setup and mode comparison.
|
||||
|
||||
### Model Switching Commands
|
||||
|
||||
@@ -346,11 +366,18 @@ ajarbot/
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
# LLM Mode (optional - defaults to agent-sdk)
|
||||
export AJARBOT_LLM_MODE="agent-sdk" # Use Pro subscription
|
||||
# OR
|
||||
export AJARBOT_LLM_MODE="api" # Use pay-per-token API
|
||||
|
||||
# Required for API mode only
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# Optional
|
||||
# Optional: Alternative LLM
|
||||
export GLM_API_KEY="..."
|
||||
|
||||
# Adapter credentials (stored in config/adapters.local.yaml)
|
||||
export AJARBOT_SLACK_BOT_TOKEN="xoxb-..."
|
||||
export AJARBOT_SLACK_APP_TOKEN="xapp-..."
|
||||
export AJARBOT_TELEGRAM_BOT_TOKEN="123456:ABC..."
|
||||
|
||||
205
SUB_AGENTS.md
Normal file
205
SUB_AGENTS.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# 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!
|
||||
@@ -139,11 +139,37 @@ class AdapterRuntime:
|
||||
if adapter:
|
||||
await adapter.send_typing_indicator(message.channel_id)
|
||||
|
||||
# Capture the event loop for thread-safe progress updates
|
||||
event_loop = asyncio.get_running_loop()
|
||||
|
||||
# Create progress callback to send updates to the user
|
||||
def progress_callback(update_message: str):
|
||||
"""Send progress updates to the user during long operations."""
|
||||
if adapter:
|
||||
try:
|
||||
# Create outbound message for progress update
|
||||
progress_msg = OutboundMessage(
|
||||
platform=message.platform,
|
||||
channel_id=message.channel_id,
|
||||
text=update_message,
|
||||
thread_id=message.thread_id,
|
||||
)
|
||||
# Run async send in a thread-safe way
|
||||
# Use the captured event loop instead of get_running_loop()
|
||||
# since this callback runs from a thread (agent.chat via to_thread)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
adapter.send_message(progress_msg),
|
||||
event_loop
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Failed to send progress update: {e}")
|
||||
|
||||
# Get response from agent (synchronous call in thread)
|
||||
response = await asyncio.to_thread(
|
||||
self.agent.chat,
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Apply postprocessors
|
||||
@@ -181,6 +207,17 @@ class AdapterRuntime:
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Error processing message: {e}")
|
||||
traceback.print_exc()
|
||||
if hasattr(self.agent, 'healing_system'):
|
||||
self.agent.healing_system.capture_error(
|
||||
error=e,
|
||||
component="adapters/runtime.py:_process_message",
|
||||
intent=f"Processing message from {message.platform}",
|
||||
context={
|
||||
"platform": message.platform,
|
||||
"user": message.username,
|
||||
"message_preview": message.text[:100],
|
||||
},
|
||||
)
|
||||
await self._send_error_reply(message)
|
||||
|
||||
async def _send_error_reply(self, message: InboundMessage) -> None:
|
||||
@@ -206,6 +243,14 @@ class AdapterRuntime:
|
||||
print("[Runtime] Starting adapter runtime...")
|
||||
await self.registry.start_all()
|
||||
|
||||
# Pass the main event loop to the LLM interface so that Agent SDK
|
||||
# async calls (from worker threads created by asyncio.to_thread)
|
||||
# can be scheduled back onto this loop via run_coroutine_threadsafe.
|
||||
loop = asyncio.get_running_loop()
|
||||
if hasattr(self.agent, 'llm') and hasattr(self.agent.llm, 'set_event_loop'):
|
||||
self.agent.llm.set_event_loop(loop)
|
||||
print("[Runtime] Event loop reference passed to LLM interface")
|
||||
|
||||
self._is_running = True
|
||||
self.message_loop_task = asyncio.create_task(
|
||||
self._process_message_queue()
|
||||
|
||||
322
agent.py
322
agent.py
@@ -1,45 +1,131 @@
|
||||
"""AI Agent with Memory and LLM Integration."""
|
||||
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
import time
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
from heartbeat import Heartbeat
|
||||
from hooks import HooksSystem
|
||||
from llm_interface import LLMInterface
|
||||
from memory_system import MemorySystem
|
||||
from self_healing import SelfHealingSystem
|
||||
from tools import TOOL_DEFINITIONS, execute_tool
|
||||
|
||||
# Maximum number of recent messages to include in LLM context
|
||||
MAX_CONTEXT_MESSAGES = 3 # Reduced from 5 to save tokens
|
||||
MAX_CONTEXT_MESSAGES = 20 # Optimized for Agent SDK flat-rate subscription
|
||||
# Maximum characters of agent response to store in memory
|
||||
MEMORY_RESPONSE_PREVIEW_LENGTH = 200
|
||||
MEMORY_RESPONSE_PREVIEW_LENGTH = 500 # Store more context for better memory retrieval
|
||||
# Maximum conversation history entries before pruning
|
||||
MAX_CONVERSATION_HISTORY = 50
|
||||
MAX_CONVERSATION_HISTORY = 100 # Higher limit with flat-rate subscription
|
||||
# Maximum tool execution iterations (generous limit for complex operations like zettelkasten)
|
||||
MAX_TOOL_ITERATIONS = 30 # Allows complex multi-step workflows with auto-linking, hybrid search, etc.
|
||||
|
||||
|
||||
class Agent:
|
||||
"""AI Agent with memory, LLM, heartbeat, and hooks."""
|
||||
"""AI Agent with memory, LLM, and hooks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str = "claude",
|
||||
workspace_dir: str = "./memory_workspace",
|
||||
enable_heartbeat: bool = False,
|
||||
is_sub_agent: bool = False,
|
||||
specialist_prompt: Optional[str] = None,
|
||||
) -> None:
|
||||
self.memory = MemorySystem(workspace_dir)
|
||||
self.llm = LLMInterface(provider)
|
||||
self.hooks = HooksSystem()
|
||||
self.conversation_history: List[dict] = []
|
||||
self._chat_lock = threading.Lock()
|
||||
self.healing_system = SelfHealingSystem(self.memory, self)
|
||||
self._progress_callback: Optional[Callable[[str], None]] = None
|
||||
self._progress_timer: Optional[threading.Timer] = None
|
||||
|
||||
# Sub-agent orchestration
|
||||
self.is_sub_agent = is_sub_agent
|
||||
self.specialist_prompt = specialist_prompt
|
||||
self.sub_agents: dict = {} # Cache for spawned sub-agents
|
||||
|
||||
self.memory.sync()
|
||||
if not is_sub_agent: # Only trigger hooks for main agent
|
||||
self.hooks.trigger("agent", "startup", {"workspace_dir": workspace_dir})
|
||||
|
||||
self.heartbeat: Optional[Heartbeat] = None
|
||||
if enable_heartbeat:
|
||||
self.heartbeat = Heartbeat(self.memory, self.llm)
|
||||
self.heartbeat.on_alert = self._on_heartbeat_alert
|
||||
self.heartbeat.start()
|
||||
def spawn_sub_agent(
|
||||
self,
|
||||
specialist_prompt: str,
|
||||
agent_id: Optional[str] = None,
|
||||
share_memory: bool = True,
|
||||
) -> 'Agent':
|
||||
"""Spawn a sub-agent with specialized system prompt.
|
||||
|
||||
Args:
|
||||
specialist_prompt: Custom system prompt for the specialist
|
||||
agent_id: Optional ID for caching (reuse same specialist)
|
||||
share_memory: Whether to share memory workspace with main agent
|
||||
|
||||
Returns:
|
||||
Agent instance configured as a specialist
|
||||
|
||||
Example:
|
||||
sub = agent.spawn_sub_agent(
|
||||
specialist_prompt="You are a zettelkasten expert. Focus ONLY on note-taking.",
|
||||
agent_id="zettelkasten_processor"
|
||||
)
|
||||
result = sub.chat("Process my fleeting notes", username="jordan")
|
||||
"""
|
||||
# Check cache if agent_id provided
|
||||
if agent_id and agent_id in self.sub_agents:
|
||||
return self.sub_agents[agent_id]
|
||||
|
||||
# Create new sub-agent
|
||||
workspace = self.memory.workspace_dir if share_memory else f"{self.memory.workspace_dir}/sub_agents/{agent_id}"
|
||||
sub_agent = Agent(
|
||||
provider=self.llm.provider,
|
||||
workspace_dir=workspace,
|
||||
is_sub_agent=True,
|
||||
specialist_prompt=specialist_prompt,
|
||||
)
|
||||
|
||||
# Cache if ID provided
|
||||
if agent_id:
|
||||
self.sub_agents[agent_id] = sub_agent
|
||||
|
||||
return sub_agent
|
||||
|
||||
def delegate(
|
||||
self,
|
||||
task: str,
|
||||
specialist_prompt: str,
|
||||
username: str = "default",
|
||||
agent_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Delegate a task to a specialist sub-agent (convenience method).
|
||||
|
||||
Args:
|
||||
task: The task/message to send to the specialist
|
||||
specialist_prompt: System prompt defining the specialist's role
|
||||
username: Username for context
|
||||
agent_id: Optional ID for caching the specialist
|
||||
|
||||
Returns:
|
||||
Response from the specialist
|
||||
|
||||
Example:
|
||||
# One-off delegation
|
||||
result = agent.delegate(
|
||||
task="Process my fleeting notes and find connections",
|
||||
specialist_prompt="You are a zettelkasten expert. Focus on note organization and linking.",
|
||||
username="jordan"
|
||||
)
|
||||
|
||||
# Cached specialist (reused across multiple calls)
|
||||
result = agent.delegate(
|
||||
task="Summarize my emails from today",
|
||||
specialist_prompt="You are an email analyst. Focus on extracting key information.",
|
||||
username="jordan",
|
||||
agent_id="email_analyst"
|
||||
)
|
||||
"""
|
||||
sub_agent = self.spawn_sub_agent(specialist_prompt, agent_id=agent_id)
|
||||
return sub_agent.chat(task, username=username)
|
||||
|
||||
def _get_context_messages(self, max_messages: int) -> List[dict]:
|
||||
"""Get recent messages without breaking tool_use/tool_result pairs.
|
||||
@@ -89,10 +175,6 @@ class Agent:
|
||||
|
||||
return result
|
||||
|
||||
def _on_heartbeat_alert(self, message: str) -> None:
|
||||
"""Handle heartbeat alerts."""
|
||||
print(f"\nHeartbeat Alert:\n{message}\n")
|
||||
|
||||
def _prune_conversation_history(self) -> None:
|
||||
"""Prune conversation history to prevent unbounded growth.
|
||||
|
||||
@@ -115,13 +197,26 @@ class Agent:
|
||||
|
||||
self.conversation_history = self.conversation_history[start_idx:]
|
||||
|
||||
def chat(self, user_message: str, username: str = "default") -> str:
|
||||
def chat(
|
||||
self,
|
||||
user_message: str,
|
||||
username: str = "default",
|
||||
progress_callback: Optional[Callable[[str], None]] = None
|
||||
) -> str:
|
||||
"""Chat with context from memory and tool use.
|
||||
|
||||
Thread-safe: uses a lock to prevent concurrent modification of
|
||||
conversation history from multiple threads (e.g., scheduled tasks
|
||||
and live messages).
|
||||
|
||||
Args:
|
||||
user_message: The user's message
|
||||
username: The user's name (default: "default")
|
||||
progress_callback: Optional callback for sending progress updates during long operations
|
||||
"""
|
||||
# Store the callback for use during the chat
|
||||
self._progress_callback = progress_callback
|
||||
|
||||
# Handle model switching commands (no lock needed, read-only on history)
|
||||
if user_message.lower().startswith("/model "):
|
||||
model_name = user_message[7:].strip()
|
||||
@@ -146,36 +241,160 @@ class Agent:
|
||||
)
|
||||
|
||||
with self._chat_lock:
|
||||
try:
|
||||
return self._chat_inner(user_message, username)
|
||||
finally:
|
||||
# Clear the callback when done
|
||||
self._progress_callback = None
|
||||
|
||||
def _build_system_prompt(self, user_message: str, username: str) -> str:
|
||||
"""Build the system prompt with SOUL, user profile, and memory context."""
|
||||
if self.specialist_prompt:
|
||||
return (
|
||||
f"{self.specialist_prompt}\n\n"
|
||||
f"You have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services. "
|
||||
f"Use them to accomplish your specialized task efficiently."
|
||||
)
|
||||
|
||||
soul = self.memory.get_soul()
|
||||
user_profile = self.memory.get_user(username)
|
||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=5)
|
||||
|
||||
memory_lines = [f"- {mem['snippet']}" for mem in relevant_memory]
|
||||
return (
|
||||
f"{soul}\n\nUser Profile:\n{user_profile}\n\n"
|
||||
f"Relevant Memory:\n" + "\n".join(memory_lines) +
|
||||
f"\n\nYou have access to tools for file operations, command execution, "
|
||||
f"web fetching, note-taking, and Google services (Gmail, Calendar, Contacts). "
|
||||
f"Use them freely to help the user."
|
||||
)
|
||||
|
||||
def _chat_inner(self, user_message: str, username: str) -> str:
|
||||
"""Inner chat logic, called while holding _chat_lock."""
|
||||
soul = self.memory.get_soul()
|
||||
user_profile = self.memory.get_user(username)
|
||||
relevant_memory = self.memory.search_hybrid(user_message, max_results=2)
|
||||
|
||||
memory_lines = [f"- {mem['snippet']}" for mem in relevant_memory]
|
||||
system = (
|
||||
f"{soul}\n\nUser Profile:\n{user_profile}\n\n"
|
||||
f"Relevant Memory:\n" + "\n".join(memory_lines) +
|
||||
f"\n\nYou have access to tools for file operations and command execution. "
|
||||
f"Use them freely to help the user."
|
||||
)
|
||||
system = self._build_system_prompt(user_message, username)
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "user", "content": user_message}
|
||||
)
|
||||
|
||||
# Prune history to prevent unbounded growth
|
||||
self._prune_conversation_history()
|
||||
|
||||
# Tool execution loop
|
||||
max_iterations = 5 # Reduced from 10 to save costs
|
||||
# Enable caching for Sonnet to save 90% on repeated system prompts
|
||||
# In Agent SDK mode, query() handles tool calls automatically via MCP.
|
||||
# The tool loop is only needed for Direct API mode.
|
||||
if self.llm.mode == "agent_sdk":
|
||||
return self._chat_agent_sdk(user_message, system)
|
||||
else:
|
||||
return self._chat_direct_api(user_message, system)
|
||||
|
||||
def _send_progress_update(self, elapsed_seconds: int):
|
||||
"""Send a progress update if callback is set."""
|
||||
if self._progress_callback:
|
||||
messages = [
|
||||
f"⏳ Still working... ({elapsed_seconds}s elapsed)",
|
||||
f"🔄 Processing your request... ({elapsed_seconds}s)",
|
||||
f"⚙️ Working on it, this might take a moment... ({elapsed_seconds}s)",
|
||||
]
|
||||
# Rotate through messages
|
||||
message = messages[(elapsed_seconds // 90) % len(messages)]
|
||||
try:
|
||||
self._progress_callback(message)
|
||||
except Exception as e:
|
||||
print(f"[Agent] Failed to send progress update: {e}")
|
||||
|
||||
def _start_progress_updates(self):
|
||||
"""Start periodic progress updates (every 90 seconds)."""
|
||||
def send_update(elapsed: int):
|
||||
self._send_progress_update(elapsed)
|
||||
# Schedule next update
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[elapsed + 90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
# Send first update after 90 seconds
|
||||
self._progress_timer = threading.Timer(90.0, send_update, args=[90])
|
||||
self._progress_timer.daemon = True
|
||||
self._progress_timer.start()
|
||||
|
||||
def _stop_progress_updates(self):
|
||||
"""Stop progress updates."""
|
||||
if self._progress_timer:
|
||||
self._progress_timer.cancel()
|
||||
self._progress_timer = None
|
||||
|
||||
def _chat_agent_sdk(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Agent SDK. Tools are handled automatically by MCP."""
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
# Start progress updates
|
||||
self._start_progress_updates()
|
||||
|
||||
try:
|
||||
# chat_with_tools() in Agent SDK mode returns a string directly.
|
||||
# The SDK handles all tool calls via MCP servers internally.
|
||||
response = self.llm.chat_with_tools(
|
||||
context_messages,
|
||||
tools=[], # Ignored in Agent SDK mode; tools come from MCP
|
||||
system=system,
|
||||
)
|
||||
except TimeoutError as e:
|
||||
error_msg = "⏱️ Task timed out after 5 minutes. The task might be too complex - try breaking it into smaller steps."
|
||||
print(f"[Agent] TIMEOUT: {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response (TIMEOUT)",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"error_type": "timeout",
|
||||
},
|
||||
)
|
||||
return error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Agent SDK error: {e}"
|
||||
print(f"[Agent] {error_msg}")
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_agent_sdk",
|
||||
intent="Calling Agent SDK for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
},
|
||||
)
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
finally:
|
||||
# Always stop progress updates when done
|
||||
self._stop_progress_updates()
|
||||
|
||||
# In Agent SDK mode, response is always a string
|
||||
final_response = response if isinstance(response, str) else str(response)
|
||||
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
self.conversation_history.append(
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
# Write compact summary to memory
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=None # SDK handles tools internally; we don't track them here
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
def _chat_direct_api(self, user_message: str, system: str) -> str:
|
||||
"""Chat using Direct API with manual tool execution loop."""
|
||||
max_iterations = MAX_TOOL_ITERATIONS
|
||||
use_caching = "sonnet" in self.llm.model.lower()
|
||||
tools_used = []
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
# Get recent messages, ensuring we don't break tool_use/tool_result pairs
|
||||
context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
|
||||
|
||||
try:
|
||||
@@ -188,11 +407,19 @@ class Agent:
|
||||
except Exception as e:
|
||||
error_msg = f"LLM API error: {e}"
|
||||
print(f"[Agent] {error_msg}")
|
||||
return f"Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
self.healing_system.capture_error(
|
||||
error=e,
|
||||
component="agent.py:_chat_direct_api",
|
||||
intent="Calling Direct API for chat response",
|
||||
context={
|
||||
"model": self.llm.model,
|
||||
"message_preview": user_message[:100],
|
||||
"iteration": iteration,
|
||||
},
|
||||
)
|
||||
return "Sorry, I encountered an error communicating with the AI model. Please try again."
|
||||
|
||||
# Check stop reason
|
||||
if response.stop_reason == "end_turn":
|
||||
# Extract text response
|
||||
text_content = []
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
@@ -200,7 +427,6 @@ class Agent:
|
||||
|
||||
final_response = "\n".join(text_content)
|
||||
|
||||
# Handle empty response
|
||||
if not final_response.strip():
|
||||
final_response = "(No response generated)"
|
||||
|
||||
@@ -208,17 +434,16 @@ class Agent:
|
||||
{"role": "assistant", "content": final_response}
|
||||
)
|
||||
|
||||
preview = final_response[:MEMORY_RESPONSE_PREVIEW_LENGTH]
|
||||
self.memory.write_memory(
|
||||
f"**User ({username})**: {user_message}\n"
|
||||
f"**Agent**: {preview}...",
|
||||
daily=True,
|
||||
compact_summary = self.memory.compact_conversation(
|
||||
user_message=user_message,
|
||||
assistant_response=final_response,
|
||||
tools_used=tools_used if tools_used else None
|
||||
)
|
||||
self.memory.write_memory(compact_summary, daily=True)
|
||||
|
||||
return final_response
|
||||
|
||||
elif response.stop_reason == "tool_use":
|
||||
# Build assistant message with tool uses
|
||||
assistant_content = []
|
||||
tool_uses = []
|
||||
|
||||
@@ -242,11 +467,11 @@ class Agent:
|
||||
"content": assistant_content
|
||||
})
|
||||
|
||||
# Execute tools and build tool result message
|
||||
tool_results = []
|
||||
for tool_use in tool_uses:
|
||||
result = execute_tool(tool_use.name, tool_use.input)
|
||||
# Truncate large tool outputs to prevent token explosion
|
||||
if tool_use.name not in tools_used:
|
||||
tools_used.append(tool_use.name)
|
||||
result = execute_tool(tool_use.name, tool_use.input, healing_system=self.healing_system)
|
||||
if len(result) > 5000:
|
||||
result = result[:5000] + "\n... (output truncated)"
|
||||
print(f"[Tool] {tool_use.name}: {result[:100]}...")
|
||||
@@ -262,7 +487,6 @@ class Agent:
|
||||
})
|
||||
|
||||
else:
|
||||
# Unexpected stop reason
|
||||
return f"Unexpected stop reason: {response.stop_reason}"
|
||||
|
||||
return "Error: Maximum tool use iterations exceeded"
|
||||
@@ -270,13 +494,9 @@ class Agent:
|
||||
def switch_model(self, provider: str) -> None:
|
||||
"""Switch LLM provider."""
|
||||
self.llm = LLMInterface(provider)
|
||||
if self.heartbeat:
|
||||
self.heartbeat.llm = self.llm
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Cleanup and stop background services."""
|
||||
if self.heartbeat:
|
||||
self.heartbeat.stop()
|
||||
self.memory.close()
|
||||
self.hooks.trigger("agent", "shutdown", {})
|
||||
|
||||
|
||||
205
ajarbot.py
Normal file
205
ajarbot.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Unified launcher for ajarbot with pre-flight checks.
|
||||
|
||||
This launcher:
|
||||
1. Performs environment checks (Node.js, Claude CLI auth)
|
||||
2. Sets sensible defaults (agent-sdk mode)
|
||||
3. Delegates to bot_runner.main() for actual execution
|
||||
|
||||
Usage:
|
||||
python ajarbot.py # Run with default config
|
||||
python ajarbot.py --config custom.yaml # Use custom config file
|
||||
python ajarbot.py --init # Generate config template
|
||||
python ajarbot.py --setup-google # Set up Google OAuth
|
||||
python ajarbot.py --health # Run health check
|
||||
|
||||
Environment variables:
|
||||
AJARBOT_LLM_MODE # LLM mode: "agent-sdk" or "api" (default: agent-sdk)
|
||||
AJARBOT_SLACK_BOT_TOKEN # Slack bot token (xoxb-...)
|
||||
AJARBOT_SLACK_APP_TOKEN # Slack app token (xapp-...)
|
||||
AJARBOT_TELEGRAM_BOT_TOKEN # Telegram bot token
|
||||
ANTHROPIC_API_KEY # Claude API key (only needed for api mode)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PreflightCheck:
|
||||
"""Performs environment checks before launching the bot."""
|
||||
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
def check_nodejs(self) -> bool:
|
||||
"""Check if Node.js is available (required for agent-sdk mode)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = result.stdout.strip()
|
||||
print(f"✓ Node.js found: {version}")
|
||||
return True
|
||||
else:
|
||||
self.warnings.append("Node.js not found (required for agent-sdk mode)")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.warnings.append("Node.js not found (required for agent-sdk mode)")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Error checking Node.js: {e}")
|
||||
return False
|
||||
|
||||
def check_claude_cli_auth(self) -> bool:
|
||||
"""Check if Claude CLI is authenticated."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "auth", "status"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0 and "Authenticated" in result.stdout:
|
||||
print("✓ Claude CLI authenticated")
|
||||
return True
|
||||
else:
|
||||
self.warnings.append("Claude CLI not authenticated (run: claude auth login)")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.warnings.append("Claude CLI not found (install from: https://claude.ai/download)")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Error checking Claude CLI: {e}")
|
||||
return False
|
||||
|
||||
def check_python_version(self) -> bool:
|
||||
"""Check if Python version is compatible."""
|
||||
version_info = sys.version_info
|
||||
if version_info >= (3, 10):
|
||||
print(f"✓ Python {version_info.major}.{version_info.minor}.{version_info.micro}")
|
||||
return True
|
||||
else:
|
||||
self.errors.append(
|
||||
f"Python 3.10+ required (found {version_info.major}.{version_info.minor}.{version_info.micro})"
|
||||
)
|
||||
return False
|
||||
|
||||
def check_env_file(self) -> bool:
|
||||
"""Check if .env file exists (for API key storage)."""
|
||||
env_path = Path(".env")
|
||||
if env_path.exists():
|
||||
print(f"✓ .env file found")
|
||||
return True
|
||||
else:
|
||||
self.warnings.append(".env file not found (create one if using API mode)")
|
||||
return False
|
||||
|
||||
def check_config_file(self) -> bool:
|
||||
"""Check if adapter config exists."""
|
||||
config_path = Path("config/adapters.local.yaml")
|
||||
if config_path.exists():
|
||||
print(f"✓ Config file found: {config_path}")
|
||||
return True
|
||||
else:
|
||||
self.warnings.append(
|
||||
"config/adapters.local.yaml not found (run: python ajarbot.py --init)"
|
||||
)
|
||||
return False
|
||||
|
||||
def set_default_llm_mode(self):
|
||||
"""Set default LLM mode to agent-sdk if not specified."""
|
||||
if "AJARBOT_LLM_MODE" not in os.environ:
|
||||
os.environ["AJARBOT_LLM_MODE"] = "agent-sdk"
|
||||
print("ℹ Using LLM mode: agent-sdk (default)")
|
||||
else:
|
||||
mode = os.environ["AJARBOT_LLM_MODE"]
|
||||
print(f"ℹ Using LLM mode: {mode} (from environment)")
|
||||
|
||||
def run_all_checks(self) -> bool:
|
||||
"""Run all pre-flight checks. Returns True if safe to proceed."""
|
||||
print("=" * 60)
|
||||
print("Ajarbot Pre-Flight Checks")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Critical checks
|
||||
self.check_python_version()
|
||||
|
||||
# LLM mode dependent checks
|
||||
llm_mode = os.environ.get("AJARBOT_LLM_MODE", "agent-sdk")
|
||||
|
||||
if llm_mode == "agent-sdk":
|
||||
print("\n[Agent SDK Mode Checks]")
|
||||
self.check_nodejs()
|
||||
self.check_claude_cli_auth()
|
||||
elif llm_mode == "api":
|
||||
print("\n[API Mode Checks]")
|
||||
has_env = self.check_env_file()
|
||||
if has_env:
|
||||
if not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
self.errors.append("ANTHROPIC_API_KEY not set in .env file (required for API mode)")
|
||||
else:
|
||||
self.errors.append(".env file with ANTHROPIC_API_KEY required for API mode")
|
||||
|
||||
# Common checks
|
||||
print("\n[Configuration Checks]")
|
||||
self.check_config_file()
|
||||
|
||||
# Display results
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
if self.errors:
|
||||
print("ERRORS (must fix before running):")
|
||||
for error in self.errors:
|
||||
print(f" ✗ {error}")
|
||||
print()
|
||||
return False
|
||||
|
||||
if self.warnings:
|
||||
print("WARNINGS (optional, but recommended):")
|
||||
for warning in self.warnings:
|
||||
print(f" ⚠ {warning}")
|
||||
print()
|
||||
|
||||
print("Pre-flight checks complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point with pre-flight checks."""
|
||||
# Set default LLM mode before checks
|
||||
checker = PreflightCheck()
|
||||
checker.set_default_llm_mode()
|
||||
|
||||
# Special commands that bypass pre-flight checks
|
||||
bypass_commands = ["--init", "--help", "-h"]
|
||||
if any(arg in sys.argv for arg in bypass_commands):
|
||||
# Import and run bot_runner directly
|
||||
from bot_runner import main as bot_main
|
||||
bot_main()
|
||||
return
|
||||
|
||||
# Run pre-flight checks for normal operation
|
||||
if not checker.run_all_checks():
|
||||
print("\nPre-flight checks failed. Please fix the errors above.")
|
||||
sys.exit(1)
|
||||
|
||||
# All checks passed - delegate to bot_runner
|
||||
print("Launching ajarbot...\n")
|
||||
from bot_runner import main as bot_main
|
||||
bot_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -77,7 +77,6 @@ class BotRunner:
|
||||
self.agent = Agent(
|
||||
provider="claude",
|
||||
workspace_dir="./memory_workspace",
|
||||
enable_heartbeat=False,
|
||||
)
|
||||
print("[Setup] Agent initialized")
|
||||
|
||||
|
||||
22
config/gitea_config.example.yaml
Normal file
22
config/gitea_config.example.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Gitea Configuration
|
||||
# Copy to gitea_config.yaml and fill in your values
|
||||
#
|
||||
# cp config/gitea_config.example.yaml config/gitea_config.yaml
|
||||
|
||||
# Gitea instance URL (no trailing slash)
|
||||
base_url: "https://vulcan.apophisnetworking.net"
|
||||
|
||||
# Personal Access Token for API authentication
|
||||
# To generate a token:
|
||||
# 1. Go to https://vulcan.apophisnetworking.net/user/settings/applications
|
||||
# 2. Under "Manage Access Tokens", enter a token name (e.g., "garvis-bot")
|
||||
# 3. Select permissions: at minimum, check "repo" (read) scope
|
||||
# 4. Click "Generate Token"
|
||||
# 5. Copy the token here (it is shown only once!)
|
||||
token: "your_personal_access_token_here"
|
||||
|
||||
# Default repository owner (used when repo is not specified in tool calls)
|
||||
default_owner: "jramos"
|
||||
|
||||
# Default repository name (used when repo is not specified in tool calls)
|
||||
default_repo: "homelab"
|
||||
113
config/obsidian_mcp.example.yaml
Normal file
113
config/obsidian_mcp.example.yaml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Obsidian MCP Server Configuration
|
||||
# ===================================
|
||||
# This file configures the external Obsidian MCP server integration.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Copy this file: copy config\obsidian_mcp.example.yaml config\obsidian_mcp.yaml
|
||||
# 2. Set your vault_path below
|
||||
# 3. Ensure Node.js 20+ is installed: node --version
|
||||
# 4. Restart the bot: python bot_runner.py
|
||||
#
|
||||
# The config file (obsidian_mcp.yaml) is gitignored to protect your vault path.
|
||||
# See OBSIDIAN_MCP_INTEGRATION.md for full documentation.
|
||||
|
||||
obsidian_mcp:
|
||||
# ---- Core Settings ----
|
||||
|
||||
# Enable or disable the Obsidian MCP integration
|
||||
# Set to false to disable without removing the config
|
||||
enabled: true
|
||||
|
||||
# Absolute path to your Obsidian vault directory
|
||||
# This MUST be the root folder of your vault (contains .obsidian/ subfolder)
|
||||
#
|
||||
# Windows examples (use double backslashes OR forward slashes):
|
||||
# "C:\\Users\\username\\Documents\\Obsidian\\MyVault"
|
||||
# "C:/Users/username/Documents/Obsidian/MyVault"
|
||||
#
|
||||
# Linux/Mac example:
|
||||
# "/home/username/obsidian-vault"
|
||||
#
|
||||
# To use the bot's built-in zettelkasten vault (same files as custom tools):
|
||||
# "C:\\Users\\username\\projects\\ajarbot\\memory_workspace\\obsidian"
|
||||
vault_path: "C:\\Users\\YOUR_USERNAME\\Documents\\Obsidian\\YOUR_VAULT"
|
||||
|
||||
# ---- Server Settings ----
|
||||
|
||||
server:
|
||||
# Command to launch the MCP server
|
||||
# Default: "npx" (downloads obsidian-mcp on first run)
|
||||
# Alternative: "node" (if installed globally)
|
||||
command: "npx"
|
||||
|
||||
# Arguments passed to the command
|
||||
# The vault_path is appended automatically as the last argument
|
||||
# Default: ["-y", "obsidian-mcp"]
|
||||
# -y = auto-confirm npm package installation
|
||||
args: ["-y", "obsidian-mcp"]
|
||||
|
||||
# Server startup timeout in seconds
|
||||
# Increase if npx is slow on first download
|
||||
startup_timeout: 30
|
||||
|
||||
# ---- Permission Controls ----
|
||||
# Control which operations the bot is allowed to perform.
|
||||
# Disable categories to restrict the bot's access to your vault.
|
||||
|
||||
permissions:
|
||||
# Read operations (safe, no changes to vault)
|
||||
# Tools: read-note, search-vault, list-available-vaults, manage-tags
|
||||
allow_read: true
|
||||
|
||||
# Write operations (creates new files or modifies existing ones)
|
||||
# Tools: create-note, edit-note
|
||||
allow_write: true
|
||||
|
||||
# Delete operations (permanently removes files)
|
||||
# Tools: delete-note
|
||||
# DISABLED by default for safety - enable only if you trust the bot
|
||||
allow_delete: false
|
||||
|
||||
# Move/rename operations (changes file paths)
|
||||
# Tools: move-note
|
||||
allow_move: true
|
||||
|
||||
# Tag operations (modifies YAML frontmatter)
|
||||
# Tools: add-tags, remove-tags, rename-tag
|
||||
allow_tags: true
|
||||
|
||||
# Directory operations (creates new folders)
|
||||
# Tools: create-directory
|
||||
allow_directories: true
|
||||
|
||||
# ---- Safety Settings ----
|
||||
|
||||
safety:
|
||||
# Require user confirmation before any write operation
|
||||
# When true, the bot will ask "Are you sure?" before creating/editing notes
|
||||
confirm_writes: false
|
||||
|
||||
# Create a backup copy before deleting a note
|
||||
# Backup is saved to .trash/ in the vault root
|
||||
backup_before_delete: true
|
||||
|
||||
# Maximum note content size in characters
|
||||
# Prevents accidental creation of very large notes
|
||||
max_note_size: 50000
|
||||
|
||||
# Directories the bot should never access or modify
|
||||
# Paths are relative to the vault root
|
||||
restricted_paths:
|
||||
- ".obsidian" # Obsidian app configuration
|
||||
- ".trash" # Obsidian trash folder
|
||||
- ".git" # Git repository data (if vault is version-controlled)
|
||||
|
||||
# ---- Environment Variable Overrides ----
|
||||
# These environment variables override the YAML settings above.
|
||||
# Add them to your .env file if you prefer not to use YAML config.
|
||||
#
|
||||
# OBSIDIAN_VAULT_PATH=C:\Users\username\Documents\Obsidian\MyVault
|
||||
# OBSIDIAN_MCP_ENABLED=true
|
||||
# OBSIDIAN_MCP_COMMAND=npx
|
||||
# OBSIDIAN_MCP_ALLOW_DELETE=false
|
||||
# OBSIDIAN_MCP_CONFIRM_WRITES=false
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
Custom Pulse & Brain configuration.
|
||||
|
||||
Define your own pulse checks (zero cost) and brain tasks (uses tokens).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
from pulse_brain import BrainTask, CheckType, PulseCheck
|
||||
|
||||
|
||||
# === PULSE CHECKS (Pure Python, Zero Cost) ===
|
||||
|
||||
|
||||
def check_server_uptime() -> Dict[str, Any]:
|
||||
"""Check if server is responsive (pure Python, no agent)."""
|
||||
try:
|
||||
response = requests.get(
|
||||
"http://localhost:8000/health", timeout=5
|
||||
)
|
||||
status = "ok" if response.status_code == 200 else "error"
|
||||
return {
|
||||
"status": status,
|
||||
"message": f"Server responded: {response.status_code}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Server unreachable: {e}",
|
||||
}
|
||||
|
||||
|
||||
def check_docker_containers() -> Dict[str, Any]:
|
||||
"""Check Docker container status (pure Python)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Status}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Docker check failed",
|
||||
}
|
||||
|
||||
unhealthy = sum(
|
||||
1
|
||||
for line in result.stdout.split("\n")
|
||||
if "unhealthy" in line.lower()
|
||||
)
|
||||
|
||||
if unhealthy > 0:
|
||||
message = f"{unhealthy} unhealthy container(s)"
|
||||
else:
|
||||
message = "All containers healthy"
|
||||
|
||||
return {
|
||||
"status": "error" if unhealthy > 0 else "ok",
|
||||
"unhealthy_count": unhealthy,
|
||||
"message": message,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
def check_plex_server() -> Dict[str, Any]:
|
||||
"""Check if Plex is running (pure Python)."""
|
||||
try:
|
||||
response = requests.get(
|
||||
"http://localhost:32400/identity", timeout=5
|
||||
)
|
||||
is_ok = response.status_code == 200
|
||||
return {
|
||||
"status": "ok" if is_ok else "warn",
|
||||
"message": (
|
||||
"Plex server is running"
|
||||
if is_ok
|
||||
else "Plex unreachable"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "warn",
|
||||
"message": f"Plex check failed: {e}",
|
||||
}
|
||||
|
||||
|
||||
def check_unifi_controller() -> Dict[str, Any]:
|
||||
"""Check UniFi controller (pure Python)."""
|
||||
try:
|
||||
requests.get(
|
||||
"https://localhost:8443", verify=False, timeout=5
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "UniFi controller responding",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"UniFi unreachable: {e}",
|
||||
}
|
||||
|
||||
|
||||
def check_gpu_temperature() -> Dict[str, Any]:
|
||||
"""Check GPU temperature (pure Python, requires nvidia-smi)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=temperature.gpu",
|
||||
"--format=csv,noheader",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "ok", "message": "GPU check skipped"}
|
||||
|
||||
temp = int(result.stdout.strip())
|
||||
|
||||
if temp > 85:
|
||||
status = "error"
|
||||
elif temp > 75:
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"temperature": temp,
|
||||
"message": f"GPU temperature: {temp}C",
|
||||
}
|
||||
except Exception:
|
||||
return {"status": "ok", "message": "GPU check skipped"}
|
||||
|
||||
|
||||
def check_star_citizen_patch() -> Dict[str, Any]:
|
||||
"""Check for Star Citizen patches (pure Python, placeholder)."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"new_patch": False,
|
||||
"message": "No new Star Citizen patches",
|
||||
}
|
||||
|
||||
|
||||
# === CUSTOM PULSE CHECKS ===
|
||||
|
||||
CUSTOM_PULSE_CHECKS: List[PulseCheck] = [
|
||||
PulseCheck(
|
||||
"server-uptime", check_server_uptime,
|
||||
interval_seconds=60,
|
||||
),
|
||||
PulseCheck(
|
||||
"docker-health", check_docker_containers,
|
||||
interval_seconds=120,
|
||||
),
|
||||
PulseCheck(
|
||||
"plex-status", check_plex_server,
|
||||
interval_seconds=300,
|
||||
),
|
||||
PulseCheck(
|
||||
"unifi-controller", check_unifi_controller,
|
||||
interval_seconds=300,
|
||||
),
|
||||
PulseCheck(
|
||||
"gpu-temp", check_gpu_temperature,
|
||||
interval_seconds=60,
|
||||
),
|
||||
PulseCheck(
|
||||
"star-citizen", check_star_citizen_patch,
|
||||
interval_seconds=3600,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# === BRAIN TASKS (Agent/SDK, Uses Tokens) ===
|
||||
|
||||
CUSTOM_BRAIN_TASKS: List[BrainTask] = [
|
||||
BrainTask(
|
||||
name="server-medic",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"Server is down!\n\n"
|
||||
"Status: $message\n\n"
|
||||
"Please analyze:\n"
|
||||
"1. What could cause this?\n"
|
||||
"2. What should I check first?\n"
|
||||
"3. Should I restart services?\n\n"
|
||||
"Be concise and actionable."
|
||||
),
|
||||
condition_func=lambda data: data.get("status") == "error",
|
||||
send_to_platform="slack",
|
||||
send_to_channel="C_ALERTS",
|
||||
),
|
||||
BrainTask(
|
||||
name="docker-diagnostician",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"Docker containers unhealthy!\n\n"
|
||||
"Unhealthy count: $unhealthy_count\n\n"
|
||||
"Please diagnose:\n"
|
||||
"1. What might cause container health issues?\n"
|
||||
"2. Should I restart them?\n"
|
||||
"3. What logs should I check?"
|
||||
),
|
||||
condition_func=lambda data: (
|
||||
data.get("unhealthy_count", 0) > 0
|
||||
),
|
||||
send_to_platform="telegram",
|
||||
send_to_channel="123456789",
|
||||
),
|
||||
BrainTask(
|
||||
name="gpu-thermal-advisor",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"GPU temperature is high!\n\n"
|
||||
"Current: $temperatureC\n\n"
|
||||
"Please advise:\n"
|
||||
"1. Is this dangerous?\n"
|
||||
"2. What can I do to cool it down?\n"
|
||||
"3. Should I stop current workloads?"
|
||||
),
|
||||
condition_func=lambda data: (
|
||||
data.get("temperature", 0) > 80
|
||||
),
|
||||
),
|
||||
BrainTask(
|
||||
name="homelab-briefing",
|
||||
check_type=CheckType.SCHEDULED,
|
||||
schedule_time="08:00",
|
||||
prompt_template=(
|
||||
"Good morning! Homelab status report:\n\n"
|
||||
"Server: $server_message\n"
|
||||
"Docker: $docker_message\n"
|
||||
"Plex: $plex_message\n"
|
||||
"UniFi: $unifi_message\n"
|
||||
"Star Citizen: $star_citizen_message\n\n"
|
||||
"Overnight summary:\n"
|
||||
"1. Any services restart?\n"
|
||||
"2. Notable events?\n"
|
||||
"3. Action items for today?\n\n"
|
||||
"Keep it brief and friendly."
|
||||
),
|
||||
send_to_platform="slack",
|
||||
send_to_channel="C_HOMELAB",
|
||||
),
|
||||
BrainTask(
|
||||
name="homelab-evening-report",
|
||||
check_type=CheckType.SCHEDULED,
|
||||
schedule_time="22:00",
|
||||
prompt_template=(
|
||||
"Evening homelab report:\n\n"
|
||||
"Today's status:\n"
|
||||
"- Server uptime: $server_message\n"
|
||||
"- Docker health: $docker_message\n"
|
||||
"- GPU temp: $gpu_message\n\n"
|
||||
"Summary:\n"
|
||||
"1. Any issues today?\n"
|
||||
"2. Services that needed attention?\n"
|
||||
"3. Overnight monitoring notes?"
|
||||
),
|
||||
send_to_platform="telegram",
|
||||
send_to_channel="123456789",
|
||||
),
|
||||
BrainTask(
|
||||
name="patch-notifier",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"New Star Citizen patch detected!\n\n"
|
||||
"Please:\n"
|
||||
"1. Summarize patch notes (if available)\n"
|
||||
"2. Note any breaking changes\n"
|
||||
"3. Recommend if I should update now or wait"
|
||||
),
|
||||
condition_func=lambda data: data.get("new_patch", False),
|
||||
send_to_platform="discord",
|
||||
send_to_channel="GAMING_CHANNEL",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def apply_custom_config(pulse_brain: Any) -> None:
|
||||
"""Apply custom configuration to PulseBrain instance."""
|
||||
existing_pulse_names = {c.name for c in pulse_brain.pulse_checks}
|
||||
for check in CUSTOM_PULSE_CHECKS:
|
||||
if check.name not in existing_pulse_names:
|
||||
pulse_brain.pulse_checks.append(check)
|
||||
|
||||
existing_brain_names = {t.name for t in pulse_brain.brain_tasks}
|
||||
for task in CUSTOM_BRAIN_TASKS:
|
||||
if task.name not in existing_brain_names:
|
||||
pulse_brain.brain_tasks.append(task)
|
||||
|
||||
print(
|
||||
f"Applied custom config: "
|
||||
f"{len(CUSTOM_PULSE_CHECKS)} pulse checks, "
|
||||
f"{len(CUSTOM_BRAIN_TASKS)} brain tasks"
|
||||
)
|
||||
@@ -1,85 +1,63 @@
|
||||
# Scheduled Tasks Configuration (EXAMPLE)
|
||||
# Copy this to scheduled_tasks.yaml and customize with your values
|
||||
# Scheduled Tasks Configuration
|
||||
# Tasks that require the Agent/LLM to execute
|
||||
#
|
||||
# Copy this file to scheduled_tasks.yaml and customize with your settings
|
||||
# scheduled_tasks.yaml is gitignored to protect personal information
|
||||
|
||||
tasks:
|
||||
# Morning briefing - sent to Slack/Telegram
|
||||
- name: morning-weather
|
||||
prompt: |
|
||||
Good morning! Please provide a weather report and daily briefing:
|
||||
Check the user profile ([username].md) for the location. Use the get_weather tool to fetch current weather.
|
||||
|
||||
1. Current weather (you can infer or say you need an API key)
|
||||
2. Any pending tasks from yesterday
|
||||
3. Priorities for today
|
||||
4. A motivational quote to start the day
|
||||
Format the report as:
|
||||
|
||||
Keep it brief and friendly.
|
||||
🌤️ **Weather Report for [Your City]**
|
||||
- Current: [current]°F
|
||||
- High: [high]°F
|
||||
- Low: [low]°F
|
||||
- Conditions: [conditions]
|
||||
- Recommendation: [brief clothing/activity suggestion]
|
||||
|
||||
Keep it brief and friendly!
|
||||
schedule: "daily 06:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID" # Replace with your Telegram user ID
|
||||
send_to_platform: "telegram" # or "slack"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||
|
||||
# Evening summary
|
||||
- name: evening-report
|
||||
# Daily Zettelkasten Review
|
||||
- name: zettelkasten-daily-review
|
||||
prompt: |
|
||||
Good evening! Time for the daily wrap-up:
|
||||
Time for your daily zettelkasten review! Help process fleeting notes:
|
||||
|
||||
1. What was accomplished today?
|
||||
2. Any tasks still pending?
|
||||
3. Preview of tomorrow's priorities
|
||||
4. Weather forecast for tomorrow (infer or API needed)
|
||||
1. Use search_by_tags to find all notes tagged with "fleeting"
|
||||
2. Show the list of fleeting notes
|
||||
3. For each note, ask: "Would you like to:
|
||||
a) Process this into a permanent note
|
||||
b) Keep as fleeting for now
|
||||
c) Delete (not useful)"
|
||||
|
||||
Keep it concise and positive.
|
||||
schedule: "daily 18:00"
|
||||
enabled: false
|
||||
Keep it conversational and low-pressure!
|
||||
schedule: "daily 20:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||
|
||||
# Hourly health check (no message sending)
|
||||
- name: system-health-check
|
||||
# Daily API cost report
|
||||
- name: daily-cost-report
|
||||
prompt: |
|
||||
Quick health check:
|
||||
Generate a daily API usage and cost report:
|
||||
|
||||
1. Are there any tasks that have been pending > 24 hours?
|
||||
2. Is the memory system healthy?
|
||||
3. Any alerts or issues?
|
||||
Read the usage_data.json file to get today's API usage statistics.
|
||||
|
||||
Respond with "HEALTHY" if all is well, otherwise describe the issue.
|
||||
schedule: "hourly"
|
||||
Format the report with today's costs, token usage, and budget tracking.
|
||||
Warn if cumulative cost exceeds 75% of budget.
|
||||
|
||||
Keep it clear and actionable!
|
||||
schedule: "daily 23:00"
|
||||
enabled: false
|
||||
username: "health-checker"
|
||||
|
||||
# Weekly review on Friday
|
||||
- name: weekly-summary
|
||||
prompt: |
|
||||
It's Friday! Time for the weekly review:
|
||||
|
||||
1. Major accomplishments this week
|
||||
2. Challenges faced and lessons learned
|
||||
3. Key metrics (tasks completed, etc.)
|
||||
4. Goals for next week
|
||||
5. Team shoutouts (if applicable)
|
||||
|
||||
Make it comprehensive but engaging.
|
||||
schedule: "weekly fri 17:00"
|
||||
enabled: false
|
||||
send_to_platform: "slack"
|
||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
||||
|
||||
# Custom: Midday standup
|
||||
- name: midday-standup
|
||||
prompt: |
|
||||
Midday check-in! Quick standup report:
|
||||
|
||||
1. Morning accomplishments
|
||||
2. Current focus
|
||||
3. Any blockers?
|
||||
4. Afternoon plan
|
||||
|
||||
Keep it brief - standup style.
|
||||
schedule: "daily 12:00"
|
||||
enabled: false
|
||||
send_to_platform: "slack"
|
||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||
|
||||
# Configuration notes:
|
||||
# - schedule formats:
|
||||
|
||||
@@ -5,12 +5,60 @@ tasks:
|
||||
# Morning briefing - sent to Slack/Telegram
|
||||
- name: morning-weather
|
||||
prompt: |
|
||||
Current weather report for my location. Just the weather - keep it brief.
|
||||
Check the user profile (Jordan.md) for the location (Centennial, CO). Use the get_weather tool with OpenWeatherMap API to fetch the current weather.
|
||||
|
||||
Also use web_fetch to get today's high/low from a weather service:
|
||||
https://wttr.in/Centennial,CO?format=j1
|
||||
|
||||
Parse the JSON response to extract:
|
||||
- maxtempF (today's high)
|
||||
- mintempF (today's low)
|
||||
|
||||
Format the report as:
|
||||
|
||||
🌤️ **Weather Report for Centennial, CO**
|
||||
- Current: [current]°F (feels like [feels_like]°F)
|
||||
- Today's High: [high]°F
|
||||
- Today's Low: [low]°F
|
||||
- Conditions: [conditions]
|
||||
- Wind: [wind speed] mph
|
||||
- Recommendation: [brief clothing/activity suggestion]
|
||||
|
||||
Keep it brief and friendly!
|
||||
schedule: "daily 06:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "8088983654" # Your Telegram user ID
|
||||
|
||||
# Daily Zettelkasten Review
|
||||
- name: zettelkasten-daily-review
|
||||
prompt: |
|
||||
Time for your daily zettelkasten review! Help Jordan process fleeting notes:
|
||||
|
||||
1. Use search_by_tags to find all notes tagged with "fleeting"
|
||||
2. Show Jordan the list of fleeting notes captured today/recently
|
||||
3. For each note, ask: "Would you like to:
|
||||
a) Process this into a permanent note
|
||||
b) Keep as fleeting for now
|
||||
c) Delete (not useful)"
|
||||
|
||||
Format:
|
||||
📝 **Daily Zettelkasten Review**
|
||||
|
||||
You have [X] fleeting notes to review:
|
||||
|
||||
1. [Title] - [first line of content]
|
||||
2. [Title] - [first line of content]
|
||||
...
|
||||
|
||||
Reply with the number to process, or 'skip' to review later.
|
||||
|
||||
Keep it conversational and low-pressure!
|
||||
schedule: "daily 20:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "8088983654"
|
||||
|
||||
# Daily API cost report
|
||||
- name: daily-cost-report
|
||||
prompt: |
|
||||
|
||||
173
examples/sub_agent_example.py
Normal file
173
examples/sub_agent_example.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Example: Using Sub-Agent Orchestration
|
||||
|
||||
This example demonstrates how to use the sub-agent system to delegate
|
||||
specialized tasks to focused agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from agent import Agent
|
||||
|
||||
|
||||
def example_1_manual_spawning():
|
||||
"""Example 1: Manually spawn and use a specialist."""
|
||||
print("=== Example 1: Manual Spawning ===\n")
|
||||
|
||||
# Create main agent
|
||||
agent = Agent(provider="claude")
|
||||
|
||||
# Spawn a zettelkasten specialist
|
||||
zettel_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. Be concise.""",
|
||||
agent_id="zettelkasten_processor" # Cached for reuse
|
||||
)
|
||||
|
||||
# Use the specialist
|
||||
result = zettel_specialist.chat(
|
||||
"Search for all fleeting notes tagged 'AI' and show me what you find.",
|
||||
username="jordan"
|
||||
)
|
||||
|
||||
print(f"Specialist Response:\n{result}\n")
|
||||
|
||||
# Reuse the cached specialist
|
||||
result2 = zettel_specialist.chat(
|
||||
"Now create a permanent note summarizing key AI concepts.",
|
||||
username="jordan"
|
||||
)
|
||||
|
||||
print(f"Second Response:\n{result2}\n")
|
||||
|
||||
|
||||
def example_2_delegation():
|
||||
"""Example 2: One-off delegation (convenience method)."""
|
||||
print("=== Example 2: Delegation ===\n")
|
||||
|
||||
agent = Agent(provider="claude")
|
||||
|
||||
# One-off delegation (specialist not cached)
|
||||
result = agent.delegate(
|
||||
task="List all files in the memory_workspace/obsidian directory",
|
||||
specialist_prompt="""You are a file system expert. Your job is to:
|
||||
- Navigate directories efficiently
|
||||
- Provide clear, organized file listings
|
||||
|
||||
Be concise and focused.""",
|
||||
username="jordan"
|
||||
)
|
||||
|
||||
print(f"Delegation Result:\n{result}\n")
|
||||
|
||||
|
||||
def example_3_cached_delegation():
|
||||
"""Example 3: Cached delegation (reuse specialist)."""
|
||||
print("=== Example 3: Cached Delegation ===\n")
|
||||
|
||||
agent = Agent(provider="claude")
|
||||
|
||||
# First call: Creates and caches the specialist
|
||||
result1 = agent.delegate(
|
||||
task="Search the zettelkasten vault for notes about 'architecture'",
|
||||
specialist_prompt="""You are a zettelkasten search expert. Your job is:
|
||||
- Use hybrid search to find relevant notes
|
||||
- Summarize key findings concisely
|
||||
|
||||
Stay focused on search and retrieval.""",
|
||||
username="jordan",
|
||||
agent_id="zettel_search" # This specialist will be cached
|
||||
)
|
||||
|
||||
print(f"First Search:\n{result1}\n")
|
||||
|
||||
# Second call: Reuses the cached specialist
|
||||
result2 = agent.delegate(
|
||||
task="Now search for notes about 'design patterns'",
|
||||
specialist_prompt="(ignored - using cached specialist)",
|
||||
username="jordan",
|
||||
agent_id="zettel_search" # Same ID = reuse cached specialist
|
||||
)
|
||||
|
||||
print(f"Second Search:\n{result2}\n")
|
||||
|
||||
|
||||
def example_4_multiple_specialists():
|
||||
"""Example 4: Use multiple specialists for different tasks."""
|
||||
print("=== Example 4: Multiple Specialists ===\n")
|
||||
|
||||
agent = Agent(provider="claude")
|
||||
|
||||
# Email specialist
|
||||
email_result = agent.delegate(
|
||||
task="Check if there are any unread emails in the last 24 hours",
|
||||
specialist_prompt="""You are an email analyst. Your job is:
|
||||
- Search and filter emails efficiently
|
||||
- Summarize key information concisely
|
||||
|
||||
Focus on email intelligence.""",
|
||||
username="jordan",
|
||||
agent_id="email_analyst"
|
||||
)
|
||||
|
||||
print(f"Email Analysis:\n{email_result}\n")
|
||||
|
||||
# Calendar specialist
|
||||
calendar_result = agent.delegate(
|
||||
task="Show me my calendar events for the next 3 days",
|
||||
specialist_prompt="""You are a calendar expert. Your job is:
|
||||
- Retrieve calendar events efficiently
|
||||
- Present schedules clearly
|
||||
|
||||
Focus on time management.""",
|
||||
username="jordan",
|
||||
agent_id="calendar_manager"
|
||||
)
|
||||
|
||||
print(f"Calendar Review:\n{calendar_result}\n")
|
||||
|
||||
|
||||
def example_5_isolated_memory():
|
||||
"""Example 5: Create specialist with isolated memory."""
|
||||
print("=== Example 5: Isolated Memory ===\n")
|
||||
|
||||
agent = Agent(provider="claude")
|
||||
|
||||
# Specialist with its own memory workspace
|
||||
specialist = agent.spawn_sub_agent(
|
||||
specialist_prompt="You are a research assistant. Focus on gathering information.",
|
||||
agent_id="researcher",
|
||||
share_memory=False # Isolated workspace
|
||||
)
|
||||
|
||||
# This specialist's memory is stored in:
|
||||
# memory_workspace/sub_agents/researcher/
|
||||
|
||||
result = specialist.chat(
|
||||
"Research the concept of 'emergence' and save findings.",
|
||||
username="jordan"
|
||||
)
|
||||
|
||||
print(f"Research Result:\n{result}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run examples
|
||||
# Uncomment the examples you want to try:
|
||||
|
||||
# example_1_manual_spawning()
|
||||
# example_2_delegation()
|
||||
# example_3_cached_delegation()
|
||||
# example_4_multiple_specialists()
|
||||
# example_5_isolated_memory()
|
||||
|
||||
print("\nℹ️ Uncomment the examples you want to run in the __main__ block")
|
||||
print("ℹ️ Note: Some examples require Google OAuth setup and active API keys")
|
||||
5
gitea_tools/__init__.py
Normal file
5
gitea_tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Gitea Tools - Private Gitea repository access for ajarbot."""
|
||||
|
||||
from .client import GiteaClient
|
||||
|
||||
__all__ = ["GiteaClient"]
|
||||
597
gitea_tools/client.py
Normal file
597
gitea_tools/client.py
Normal file
@@ -0,0 +1,597 @@
|
||||
"""Gitea API Client - Access private Gitea repositories.
|
||||
|
||||
Uses Gitea's REST API (compatible with GitHub API v3) to read files,
|
||||
list directories, search code, and get directory trees from private repos.
|
||||
|
||||
Authentication via Personal Access Token configured in config/gitea_config.yaml.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Config file path
|
||||
_CONFIG_PATH = Path("config/gitea_config.yaml")
|
||||
|
||||
# Request timeout (seconds)
|
||||
_REQUEST_TIMEOUT = 10.0
|
||||
|
||||
# Maximum file size to return (1MB)
|
||||
_MAX_FILE_SIZE = 1_000_000
|
||||
|
||||
# Maximum output characters (prevents token explosion)
|
||||
_MAX_OUTPUT_CHARS = 5000
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for Gitea REST API with Personal Access Token authentication."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
default_owner: Optional[str] = None,
|
||||
default_repo: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialize Gitea client.
|
||||
|
||||
Args:
|
||||
base_url: Gitea instance URL (e.g., "https://vulcan.apophisnetworking.net").
|
||||
token: Personal Access Token for authentication.
|
||||
default_owner: Default repository owner (e.g., "jramos").
|
||||
default_repo: Default repository name (e.g., "homelab").
|
||||
|
||||
If arguments are not provided, reads from config/gitea_config.yaml.
|
||||
"""
|
||||
config = self._load_config()
|
||||
|
||||
self.base_url = (base_url or config.get("base_url", "")).rstrip("/")
|
||||
self.token = token or config.get("token", "")
|
||||
self.default_owner = default_owner or config.get("default_owner", "")
|
||||
self.default_repo = default_repo or config.get("default_repo", "")
|
||||
|
||||
if not self.base_url:
|
||||
raise ValueError(
|
||||
"Gitea base_url not configured. "
|
||||
"Set it in config/gitea_config.yaml or pass base_url argument."
|
||||
)
|
||||
if not self.token:
|
||||
raise ValueError(
|
||||
"Gitea token not configured. "
|
||||
"Create a Personal Access Token at "
|
||||
f"{self.base_url}/user/settings/applications "
|
||||
"and add it to config/gitea_config.yaml"
|
||||
)
|
||||
|
||||
self.api_url = f"{self.base_url}/api/v1"
|
||||
|
||||
logger.info(
|
||||
"[Gitea] Client initialized: %s (default: %s/%s)",
|
||||
self.base_url,
|
||||
self.default_owner,
|
||||
self.default_repo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from YAML file."""
|
||||
if not _CONFIG_PATH.exists():
|
||||
logger.warning(
|
||||
"[Gitea] Config file not found: %s. "
|
||||
"Copy config/gitea_config.example.yaml to config/gitea_config.yaml",
|
||||
_CONFIG_PATH,
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
content = _CONFIG_PATH.read_text(encoding="utf-8")
|
||||
config = yaml.safe_load(content) or {}
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error("[Gitea] Failed to load config: %s", e)
|
||||
return {}
|
||||
|
||||
def _parse_repo(
|
||||
self,
|
||||
repo: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""Parse owner/repo from various input formats.
|
||||
|
||||
Args:
|
||||
repo: Repository in "owner/repo" format, or just "repo" name.
|
||||
owner: Explicit owner (overrides repo string parsing).
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo) strings.
|
||||
"""
|
||||
if repo and "/" in repo:
|
||||
parts = repo.split("/", 1)
|
||||
parsed_owner = parts[0]
|
||||
parsed_repo = parts[1]
|
||||
else:
|
||||
parsed_owner = owner or self.default_owner
|
||||
parsed_repo = repo or self.default_repo
|
||||
|
||||
if owner:
|
||||
parsed_owner = owner
|
||||
|
||||
if not parsed_owner or not parsed_repo:
|
||||
raise ValueError(
|
||||
f"Repository not specified. Provide repo as 'owner/repo' "
|
||||
f"or configure default_owner/default_repo in gitea_config.yaml. "
|
||||
f"Got owner='{parsed_owner}', repo='{parsed_repo}'"
|
||||
)
|
||||
|
||||
return parsed_owner, parsed_repo
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
"""Build request headers with authentication."""
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Garvis/1.0 (Ajarbot Gitea Integration)",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an authenticated API request.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.).
|
||||
endpoint: API endpoint path (e.g., "/repos/jramos/homelab/contents/README.md").
|
||||
params: Optional query parameters.
|
||||
|
||||
Returns:
|
||||
Dict with "success" key and either "data" or "error".
|
||||
"""
|
||||
url = f"{self.api_url}{endpoint}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
verify=True,
|
||||
headers=self._headers(),
|
||||
) as client:
|
||||
response = await client.request(method, url, params=params)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Authentication failed (HTTP 401). "
|
||||
"Check your Personal Access Token in config/gitea_config.yaml. "
|
||||
f"Generate a new token at: {self.base_url}/user/settings/applications"
|
||||
),
|
||||
}
|
||||
elif response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Not found (HTTP 404): {endpoint}",
|
||||
}
|
||||
elif response.status_code >= 400:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"HTTP {response.status_code}: {response.text[:200]}",
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
return {"success": True, "data": data}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Request to {self.base_url} timed out after {_REQUEST_TIMEOUT}s",
|
||||
}
|
||||
except httpx.ConnectError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Connection failed to {self.base_url}: {e}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Request failed: {str(e)}",
|
||||
}
|
||||
|
||||
async def get_file_content(
|
||||
self,
|
||||
file_path: str,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get raw file content from a repository.
|
||||
|
||||
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{filepath}
|
||||
|
||||
Args:
|
||||
file_path: Path to file in repo (e.g., "scripts/proxmox_collector.py").
|
||||
owner: Repository owner (default: from config).
|
||||
repo: Repository name or "owner/repo" (default: from config).
|
||||
branch: Branch name (default: repo default branch).
|
||||
|
||||
Returns:
|
||||
Dict with "success", and either "content"/"metadata" or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Normalize file path (remove leading slash)
|
||||
file_path = file_path.lstrip("/")
|
||||
|
||||
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{file_path}"
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
result = await self._request("GET", endpoint, params=params)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
data = result["data"]
|
||||
|
||||
# Handle case where path is a directory (returns a list)
|
||||
if isinstance(data, list):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"'{file_path}' is a directory, not a file. "
|
||||
f"Use gitea_list_files to browse directories."
|
||||
),
|
||||
}
|
||||
|
||||
# Check file size
|
||||
file_size = data.get("size", 0)
|
||||
if file_size > _MAX_FILE_SIZE:
|
||||
return {
|
||||
"success": True,
|
||||
"content": (
|
||||
f"[File too large: {file_size:,} bytes ({file_size / 1024 / 1024:.1f} MB). "
|
||||
f"Maximum is {_MAX_FILE_SIZE:,} bytes. "
|
||||
f"Use the download URL to fetch it directly.]"
|
||||
),
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"download_url": data.get("download_url", ""),
|
||||
"sha": data.get("sha", ""),
|
||||
},
|
||||
}
|
||||
|
||||
# Decode base64 content
|
||||
encoded_content = data.get("content", "")
|
||||
try:
|
||||
content = base64.b64decode(encoded_content).decode("utf-8")
|
||||
except (UnicodeDecodeError, Exception):
|
||||
return {
|
||||
"success": True,
|
||||
"content": "[Binary file - cannot display as text]",
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"encoding": data.get("encoding", ""),
|
||||
"download_url": data.get("download_url", ""),
|
||||
},
|
||||
}
|
||||
|
||||
# Truncate if too long
|
||||
truncated = False
|
||||
if len(content) > _MAX_OUTPUT_CHARS:
|
||||
content = content[:_MAX_OUTPUT_CHARS] + "\n\n... (file truncated)"
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"content": content,
|
||||
"metadata": {
|
||||
"name": data.get("name", ""),
|
||||
"path": data.get("path", ""),
|
||||
"size": file_size,
|
||||
"sha": data.get("sha", ""),
|
||||
"last_commit_sha": data.get("last_commit_sha", ""),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"truncated": truncated,
|
||||
},
|
||||
}
|
||||
|
||||
async def list_files(
|
||||
self,
|
||||
path: str = "",
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""List files and directories at a path in the repository.
|
||||
|
||||
Uses Gitea Contents API: GET /repos/{owner}/{repo}/contents/{path}
|
||||
|
||||
Args:
|
||||
path: Directory path in repo (e.g., "scripts/"). Empty for root.
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
branch: Branch name.
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "files" list or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Normalize path
|
||||
path = path.strip("/")
|
||||
|
||||
endpoint = f"/repos/{parsed_owner}/{parsed_repo}/contents/{path}" if path else f"/repos/{parsed_owner}/{parsed_repo}/contents"
|
||||
params = {}
|
||||
if branch:
|
||||
params["ref"] = branch
|
||||
|
||||
result = await self._request("GET", endpoint, params=params)
|
||||
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
data = result["data"]
|
||||
|
||||
# If it's a single file (not a directory), inform the user
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"'{path}' is a file, not a directory. "
|
||||
f"Use gitea_read_file to read file contents."
|
||||
),
|
||||
}
|
||||
|
||||
# Build file listing
|
||||
files = []
|
||||
for item in data:
|
||||
entry = {
|
||||
"name": item.get("name", ""),
|
||||
"type": item.get("type", ""), # "file" or "dir"
|
||||
"path": item.get("path", ""),
|
||||
"size": item.get("size", 0),
|
||||
}
|
||||
files.append(entry)
|
||||
|
||||
# Sort: directories first, then files, alphabetically
|
||||
files.sort(key=lambda f: (0 if f["type"] == "dir" else 1, f["name"].lower()))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"files": files,
|
||||
"path": path or "/",
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"count": len(files),
|
||||
}
|
||||
|
||||
async def search_code(
|
||||
self,
|
||||
query: str,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for code in a repository.
|
||||
|
||||
Uses Gitea Code Search API: GET /repos/{owner}/{repo}/topics (fallback)
|
||||
or the general search: GET /repos/search
|
||||
|
||||
Note: Gitea's code search depends on indexer configuration.
|
||||
Falls back to repo-level search if code search is unavailable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "results" or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# Try Gitea's code search endpoint first
|
||||
# GET /repos/{owner}/{repo}/contents - search by traversing
|
||||
# Gitea doesn't have a direct per-repo code search API like GitHub
|
||||
# Use the global code search with repo filter
|
||||
endpoint = "/repos/search"
|
||||
params = {
|
||||
"q": query,
|
||||
"owner": parsed_owner,
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
# First try: global code search (if Gitea has it enabled)
|
||||
code_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/grep"
|
||||
code_params = {"query": query}
|
||||
|
||||
# Gitea doesn't have a git grep API, use the topic/label search
|
||||
# or fall back to listing + content search
|
||||
# Best approach: use the Gitea search API
|
||||
search_endpoint = "/repos/search"
|
||||
search_params = {
|
||||
"q": query,
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
# For code search, Gitea's best option is the global search endpoint
|
||||
# with topic filter. But for actual file content search, we need to
|
||||
# traverse the tree and search file contents.
|
||||
# Use a pragmatic approach: get the repo tree and search filenames
|
||||
# and provide useful results.
|
||||
|
||||
# Strategy: Get flat tree, filter by query in filename and path
|
||||
tree_result = await self.get_tree(
|
||||
owner=parsed_owner,
|
||||
repo=parsed_repo,
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
if not tree_result["success"]:
|
||||
return tree_result
|
||||
|
||||
entries = tree_result.get("entries", [])
|
||||
query_lower = query.lower()
|
||||
|
||||
# Search filenames and paths
|
||||
matches = []
|
||||
for entry in entries:
|
||||
path = entry.get("path", "")
|
||||
if query_lower in path.lower():
|
||||
matches.append({
|
||||
"path": path,
|
||||
"type": entry.get("type", ""),
|
||||
"size": entry.get("size", 0),
|
||||
"match_type": "filename",
|
||||
})
|
||||
|
||||
# Limit results
|
||||
matches = matches[:20]
|
||||
|
||||
if not matches:
|
||||
return {
|
||||
"success": True,
|
||||
"results": [],
|
||||
"query": query,
|
||||
"message": (
|
||||
f"No files matching '{query}' found in "
|
||||
f"{parsed_owner}/{parsed_repo}. "
|
||||
f"Note: This searches file/directory names only. "
|
||||
f"For content search, read specific files with gitea_read_file."
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": matches,
|
||||
"query": query,
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"count": len(matches),
|
||||
}
|
||||
|
||||
async def get_tree(
|
||||
self,
|
||||
owner: Optional[str] = None,
|
||||
repo: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the directory tree of a repository.
|
||||
|
||||
Uses Gitea Git Trees API: GET /repos/{owner}/{repo}/git/trees/{sha}
|
||||
|
||||
Args:
|
||||
owner: Repository owner.
|
||||
repo: Repository name or "owner/repo".
|
||||
branch: Branch name (default: repo default branch).
|
||||
recursive: If True, get full recursive tree.
|
||||
|
||||
Returns:
|
||||
Dict with "success" and either "entries" list or "error".
|
||||
"""
|
||||
parsed_owner, parsed_repo = self._parse_repo(repo, owner)
|
||||
|
||||
# First, get the branch SHA (or default branch)
|
||||
ref = branch or "main"
|
||||
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||
branch_result = await self._request("GET", branch_endpoint)
|
||||
|
||||
if not branch_result["success"]:
|
||||
# Try "master" as fallback
|
||||
if not branch and "404" in branch_result.get("error", ""):
|
||||
ref = "master"
|
||||
branch_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/branches/{ref}"
|
||||
branch_result = await self._request("GET", branch_endpoint)
|
||||
|
||||
if not branch_result["success"]:
|
||||
return branch_result
|
||||
|
||||
branch_data = branch_result["data"]
|
||||
tree_sha = branch_data.get("commit", {}).get("id", "")
|
||||
|
||||
if not tree_sha:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not get tree SHA for branch '{ref}'",
|
||||
}
|
||||
|
||||
# Get the tree
|
||||
tree_endpoint = f"/repos/{parsed_owner}/{parsed_repo}/git/trees/{tree_sha}"
|
||||
params = {}
|
||||
if recursive:
|
||||
params["recursive"] = "true"
|
||||
|
||||
tree_result = await self._request("GET", tree_endpoint, params=params)
|
||||
|
||||
if not tree_result["success"]:
|
||||
return tree_result
|
||||
|
||||
tree_data = tree_result["data"]
|
||||
raw_entries = tree_data.get("tree", [])
|
||||
|
||||
# Format entries
|
||||
entries = []
|
||||
for entry in raw_entries:
|
||||
entry_type = entry.get("type", "")
|
||||
# Map git object types to readable types
|
||||
if entry_type == "blob":
|
||||
readable_type = "file"
|
||||
elif entry_type == "tree":
|
||||
readable_type = "dir"
|
||||
else:
|
||||
readable_type = entry_type
|
||||
|
||||
entries.append({
|
||||
"path": entry.get("path", ""),
|
||||
"type": readable_type,
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
})
|
||||
|
||||
# Sort: directories first, then files
|
||||
entries.sort(key=lambda e: (0 if e["type"] == "dir" else 1, e["path"].lower()))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"entries": entries,
|
||||
"branch": ref,
|
||||
"repo": f"{parsed_owner}/{parsed_repo}",
|
||||
"total": len(entries),
|
||||
"truncated": tree_data.get("truncated", False),
|
||||
}
|
||||
|
||||
|
||||
# Singleton client instance (lazy-loaded)
|
||||
_gitea_client: Optional[GiteaClient] = None
|
||||
|
||||
|
||||
def get_gitea_client() -> Optional[GiteaClient]:
|
||||
"""Get or create the singleton Gitea client.
|
||||
|
||||
Returns None if configuration is missing or invalid.
|
||||
"""
|
||||
global _gitea_client
|
||||
|
||||
if _gitea_client is not None:
|
||||
return _gitea_client
|
||||
|
||||
try:
|
||||
_gitea_client = GiteaClient()
|
||||
return _gitea_client
|
||||
except ValueError as e:
|
||||
logger.warning("[Gitea] Client not available: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[Gitea] Failed to initialize client: %s", e)
|
||||
return None
|
||||
192
heartbeat.py
192
heartbeat.py
@@ -1,192 +0,0 @@
|
||||
"""Simple Heartbeat System - Periodic agent awareness checks."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from llm_interface import LLMInterface
|
||||
from memory_system import MemorySystem
|
||||
|
||||
# Default heartbeat checklist template
|
||||
_HEARTBEAT_TEMPLATE = """\
|
||||
# Heartbeat Checklist
|
||||
|
||||
Run these checks every heartbeat cycle:
|
||||
|
||||
## Memory Checks
|
||||
- Review pending tasks (status = pending)
|
||||
- Check if any tasks have been pending > 24 hours
|
||||
|
||||
## System Checks
|
||||
- Verify memory system is synced
|
||||
- Log heartbeat ran successfully
|
||||
|
||||
## Notes
|
||||
- Return HEARTBEAT_OK if nothing needs attention
|
||||
- Only alert if something requires user action
|
||||
"""
|
||||
|
||||
# Maximum number of pending tasks to include in context
|
||||
MAX_PENDING_TASKS_IN_CONTEXT = 5
|
||||
|
||||
# Maximum characters of soul content to include in context
|
||||
SOUL_PREVIEW_LENGTH = 200
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
"""Periodic background checks with LLM awareness."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: MemorySystem,
|
||||
llm: LLMInterface,
|
||||
interval_minutes: int = 30,
|
||||
active_hours: tuple = (8, 22),
|
||||
) -> None:
|
||||
self.memory = memory
|
||||
self.llm = llm
|
||||
self.interval = interval_minutes * 60
|
||||
self.active_hours = active_hours
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.on_alert: Optional[Callable[[str], None]] = None
|
||||
|
||||
self.heartbeat_file = memory.workspace_dir / "HEARTBEAT.md"
|
||||
if not self.heartbeat_file.exists():
|
||||
self.heartbeat_file.write_text(
|
||||
_HEARTBEAT_TEMPLATE, encoding="utf-8"
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start heartbeat in background thread."""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(
|
||||
target=self._heartbeat_loop, daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
print(f"Heartbeat started (every {self.interval // 60}min)")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop heartbeat."""
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
print("Heartbeat stopped")
|
||||
|
||||
def _is_active_hours(self) -> bool:
|
||||
"""Check if current time is within active hours."""
|
||||
current_hour = datetime.now().hour
|
||||
start, end = self.active_hours
|
||||
return start <= current_hour < end
|
||||
|
||||
def _heartbeat_loop(self) -> None:
|
||||
"""Main heartbeat loop."""
|
||||
while self.running:
|
||||
try:
|
||||
if self._is_active_hours():
|
||||
self._run_heartbeat()
|
||||
else:
|
||||
start, end = self.active_hours
|
||||
print(
|
||||
f"Heartbeat skipped "
|
||||
f"(outside active hours {start}-{end})"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Heartbeat error: {e}")
|
||||
|
||||
time.sleep(self.interval)
|
||||
|
||||
def _build_context(self) -> str:
|
||||
"""Build system context for heartbeat check."""
|
||||
soul = self.memory.get_soul()
|
||||
pending_tasks = self.memory.get_tasks(status="pending")
|
||||
|
||||
context_parts = [
|
||||
"# HEARTBEAT CHECK",
|
||||
f"Current time: {datetime.now().isoformat()}",
|
||||
f"\nSOUL:\n{soul[:SOUL_PREVIEW_LENGTH]}...",
|
||||
f"\nPending tasks: {len(pending_tasks)}",
|
||||
]
|
||||
|
||||
if pending_tasks:
|
||||
context_parts.append("\nPending Tasks:")
|
||||
for task in pending_tasks[:MAX_PENDING_TASKS_IN_CONTEXT]:
|
||||
context_parts.append(f"- [{task['id']}] {task['title']}")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def _run_heartbeat(self) -> None:
|
||||
"""Execute one heartbeat cycle."""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"Heartbeat running ({timestamp})")
|
||||
|
||||
checklist = self.heartbeat_file.read_text(encoding="utf-8")
|
||||
system = self._build_context()
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{checklist}\n\n"
|
||||
"Process this checklist. If nothing needs attention, "
|
||||
"respond with EXACTLY 'HEARTBEAT_OK'. If something "
|
||||
"needs attention, describe it briefly."
|
||||
),
|
||||
}]
|
||||
|
||||
response = self.llm.chat(messages, system=system, max_tokens=500)
|
||||
|
||||
if response.strip() != "HEARTBEAT_OK":
|
||||
print(f"Heartbeat alert: {response[:100]}...")
|
||||
if self.on_alert:
|
||||
self.on_alert(response)
|
||||
self.memory.write_memory(
|
||||
f"## Heartbeat Alert\n{response}", daily=True
|
||||
)
|
||||
else:
|
||||
print("Heartbeat OK")
|
||||
|
||||
def check_now(self) -> str:
|
||||
"""Run heartbeat check immediately (for testing)."""
|
||||
print("Running immediate heartbeat check...")
|
||||
|
||||
checklist = self.heartbeat_file.read_text(encoding="utf-8")
|
||||
pending_tasks = self.memory.get_tasks(status="pending")
|
||||
soul = self.memory.get_soul()
|
||||
|
||||
system = (
|
||||
f"Time: {datetime.now().isoformat()}\n"
|
||||
f"SOUL: {soul[:SOUL_PREVIEW_LENGTH]}...\n"
|
||||
f"Pending tasks: {len(pending_tasks)}"
|
||||
)
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"{checklist}\n\n"
|
||||
"Process this checklist. "
|
||||
"Return HEARTBEAT_OK if nothing needs attention."
|
||||
),
|
||||
}]
|
||||
|
||||
return self.llm.chat(messages, system=system, max_tokens=500)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
memory = MemorySystem()
|
||||
llm = LLMInterface(provider="claude")
|
||||
|
||||
heartbeat = Heartbeat(
|
||||
memory, llm, interval_minutes=30, active_hours=(8, 22)
|
||||
)
|
||||
|
||||
def on_alert(message: str) -> None:
|
||||
print(f"\nALERT: {message}\n")
|
||||
|
||||
heartbeat.on_alert = on_alert
|
||||
|
||||
result = heartbeat.check_now()
|
||||
print(f"\nResult: {result}")
|
||||
554
llm_interface.py
554
llm_interface.py
@@ -1,31 +1,114 @@
|
||||
"""LLM Interface - Claude API, GLM, and other models."""
|
||||
"""LLM Interface - Claude API, GLM, and other models.
|
||||
|
||||
Supports two modes for Claude:
|
||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||
- Set USE_AGENT_SDK=true (default)
|
||||
- Model: claude-sonnet-4-5-20250929 (default for all operations)
|
||||
- All tools are MCP-based (no API key needed)
|
||||
- Tools registered via mcp_tools.py MCP server
|
||||
- Flat-rate subscription cost
|
||||
|
||||
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
||||
- Model: claude-sonnet-4-5-20250929
|
||||
- Requires ANTHROPIC_API_KEY in .env
|
||||
- Uses traditional tool definitions from tools.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import requests
|
||||
from anthropic import Anthropic
|
||||
from anthropic.types import Message
|
||||
|
||||
from usage_tracker import UsageTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Ensure our debug messages are visible even if root logger is at WARNING.
|
||||
# Only add a handler if none exist (prevent duplicate output).
|
||||
if not logger.handlers:
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
))
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Try to import Agent SDK (optional dependency)
|
||||
try:
|
||||
from claude_agent_sdk import (
|
||||
ClaudeAgentOptions,
|
||||
ResultMessage,
|
||||
)
|
||||
AGENT_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
AGENT_SDK_AVAILABLE = False
|
||||
|
||||
# API key environment variable names by provider
|
||||
_API_KEY_ENV_VARS = {
|
||||
"claude": "ANTHROPIC_API_KEY",
|
||||
"glm": "GLM_API_KEY",
|
||||
}
|
||||
|
||||
# Mode selection (priority: USE_DIRECT_API > default to Agent SDK)
|
||||
_USE_DIRECT_API = os.getenv("USE_DIRECT_API", "false").lower() == "true"
|
||||
_USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
||||
|
||||
# Default models by provider
|
||||
_DEFAULT_MODELS = {
|
||||
"claude": "claude-haiku-4-5-20251001", # 12x cheaper than Sonnet!
|
||||
"claude": "claude-sonnet-4-5-20250929",
|
||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
||||
"glm": "glm-4-plus",
|
||||
}
|
||||
|
||||
_GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
|
||||
# Track PIDs of claude.exe subprocesses we spawn (to avoid killing user's Claude Code session!)
|
||||
_TRACKED_CLAUDE_PIDS: Set[int] = set()
|
||||
_TRACKED_PIDS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _register_claude_subprocess(pid: int):
|
||||
"""Register a claude.exe subprocess PID for cleanup on exit."""
|
||||
with _TRACKED_PIDS_LOCK:
|
||||
_TRACKED_CLAUDE_PIDS.add(pid)
|
||||
logger.debug("[LLM] Registered claude.exe subprocess PID: %d", pid)
|
||||
|
||||
|
||||
def _cleanup_tracked_claude_processes():
|
||||
"""Kill only the claude.exe processes we spawned (not the user's Claude Code session!)"""
|
||||
with _TRACKED_PIDS_LOCK:
|
||||
if not _TRACKED_CLAUDE_PIDS:
|
||||
return
|
||||
|
||||
logger.info("[LLM] Cleaning up %d tracked claude.exe subprocess(es)", len(_TRACKED_CLAUDE_PIDS))
|
||||
for pid in _TRACKED_CLAUDE_PIDS:
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
subprocess.run(
|
||||
['taskkill', '/F', '/PID', str(pid), '/T'],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
else: # Linux/Mac
|
||||
subprocess.run(['kill', '-9', str(pid)], capture_output=True, timeout=2)
|
||||
logger.debug("[LLM] Killed claude.exe subprocess PID: %d", pid)
|
||||
except Exception as e:
|
||||
logger.debug("[LLM] Failed to kill PID %d: %s", pid, e)
|
||||
|
||||
_TRACKED_CLAUDE_PIDS.clear()
|
||||
|
||||
|
||||
# Register cleanup on exit (only kills our tracked subprocesses, not all claude.exe!)
|
||||
atexit.register(_cleanup_tracked_claude_processes)
|
||||
|
||||
|
||||
class LLMInterface:
|
||||
"""Simple LLM interface supporting Claude and GLM."""
|
||||
"""LLM interface supporting Claude (Agent SDK or Direct API) and GLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -37,27 +120,182 @@ class LLMInterface:
|
||||
self.api_key = api_key or os.getenv(
|
||||
_API_KEY_ENV_VARS.get(provider, ""),
|
||||
)
|
||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||
self.client: Optional[Anthropic] = None
|
||||
|
||||
# Usage tracking
|
||||
self.tracker = UsageTracker() if track_usage else None
|
||||
# Reference to the main asyncio event loop, set by the runtime.
|
||||
# Used by Agent SDK mode to schedule async work from worker threads
|
||||
# via asyncio.run_coroutine_threadsafe().
|
||||
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Determine mode (priority: direct API > agent SDK)
|
||||
if provider == "claude":
|
||||
if _USE_DIRECT_API:
|
||||
self.mode = "direct_api"
|
||||
elif _USE_AGENT_SDK and AGENT_SDK_AVAILABLE:
|
||||
self.mode = "agent_sdk"
|
||||
else:
|
||||
self.mode = "direct_api"
|
||||
if _USE_AGENT_SDK and not AGENT_SDK_AVAILABLE:
|
||||
print("[LLM] Warning: Agent SDK not available, falling back to Direct API")
|
||||
print("[LLM] Install with: pip install claude-agent-sdk")
|
||||
else:
|
||||
self.mode = "direct_api"
|
||||
|
||||
# Usage tracking (only for Direct API pay-per-token mode)
|
||||
self.tracker = UsageTracker() if (track_usage and self.mode == "direct_api") else None
|
||||
|
||||
# Set model based on mode
|
||||
if provider == "claude":
|
||||
if self.mode == "agent_sdk":
|
||||
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-5-20250929")
|
||||
else:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
||||
else:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||
|
||||
# Initialize based on mode
|
||||
if provider == "claude":
|
||||
if self.mode == "agent_sdk":
|
||||
print(f"[LLM] Using Agent SDK (Max subscription) with model: {self.model}")
|
||||
elif self.mode == "direct_api":
|
||||
print(f"[LLM] Using Direct API (pay-per-token) with model: {self.model}")
|
||||
self.client = Anthropic(api_key=self.api_key)
|
||||
|
||||
def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Store a reference to the main asyncio event loop.
|
||||
|
||||
This allows Agent SDK async calls to be scheduled back onto the
|
||||
main event loop from worker threads (created by asyncio.to_thread).
|
||||
Must be called from the async context that owns the loop.
|
||||
"""
|
||||
self._event_loop = loop
|
||||
logger.info(
|
||||
"[LLM] Event loop stored: %s (running=%s)",
|
||||
type(loop).__name__,
|
||||
loop.is_running(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clean_claude_env() -> dict:
|
||||
"""Remove Claude Code session markers from the environment.
|
||||
|
||||
The Agent SDK's SubprocessCLITransport copies os.environ into the
|
||||
child process. If the bot is launched from within a Claude Code
|
||||
session (or any environment that sets CLAUDECODE), the child
|
||||
``claude`` CLI detects the nesting and refuses to start with:
|
||||
|
||||
"Claude Code cannot be launched inside another Claude Code session."
|
||||
|
||||
This method temporarily removes the offending variables and returns
|
||||
them so the caller can restore them afterwards.
|
||||
"""
|
||||
saved = {}
|
||||
# Keys that signal an active Claude Code parent session.
|
||||
# CLAUDE_CODE_ENTRYPOINT and CLAUDE_AGENT_SDK_VERSION are set by
|
||||
# the SDK itself on the child process, so removing them from the
|
||||
# parent is safe -- the SDK will set them again.
|
||||
markers = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_AGENT_SDK_VERSION",
|
||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||
]
|
||||
for key in markers:
|
||||
if key in os.environ:
|
||||
saved[key] = os.environ.pop(key)
|
||||
if saved:
|
||||
logger.debug("[LLM] Cleaned Claude session env vars: %s", list(saved.keys()))
|
||||
return saved
|
||||
|
||||
@staticmethod
|
||||
def _restore_claude_env(saved: dict) -> None:
|
||||
"""Restore previously removed Claude session env vars."""
|
||||
os.environ.update(saved)
|
||||
|
||||
def _run_async_from_thread(self, coro) -> Any:
|
||||
"""Run an async coroutine from a synchronous worker thread.
|
||||
|
||||
Uses asyncio.run_coroutine_threadsafe() to schedule the coroutine
|
||||
on the main event loop (if available), which is the correct way to
|
||||
bridge sync -> async when called from an asyncio.to_thread() worker
|
||||
or from any background thread (e.g., the scheduler).
|
||||
|
||||
Falls back to asyncio.run() if no event loop reference is available
|
||||
(e.g., direct script usage without the adapter runtime).
|
||||
|
||||
Args:
|
||||
coro: An already-created coroutine object (not a coroutine function).
|
||||
"""
|
||||
current_thread = threading.current_thread().name
|
||||
has_loop = self._event_loop is not None
|
||||
loop_running = has_loop and self._event_loop.is_running()
|
||||
|
||||
if has_loop and loop_running:
|
||||
logger.info(
|
||||
"[LLM] _run_async_from_thread: using run_coroutine_threadsafe "
|
||||
"(thread=%s, loop=%s)",
|
||||
current_thread,
|
||||
type(self._event_loop).__name__,
|
||||
)
|
||||
# Schedule on the main event loop and block this thread until done.
|
||||
# This works because:
|
||||
# 1. asyncio.to_thread() runs us in a thread pool while the main
|
||||
# loop continues processing other tasks.
|
||||
# 2. Scheduler threads are plain daemon threads, also not blocking
|
||||
# the main loop.
|
||||
# The coroutine executes on the main loop without deadlocking
|
||||
# because the main loop is free to run while we block here.
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._event_loop)
|
||||
try:
|
||||
# Block with 10-minute timeout to prevent hangs
|
||||
# Complex tasks (repo analysis, multi-step operations) can take 5-8 minutes
|
||||
logger.info("[LLM] Waiting for Agent SDK response (timeout: 600s)...")
|
||||
result = future.result(timeout=600)
|
||||
logger.info("[LLM] Agent SDK response received successfully")
|
||||
return result
|
||||
except TimeoutError:
|
||||
logger.error("[LLM] ⚠️ Agent SDK call TIMED OUT after 600 seconds!")
|
||||
future.cancel() # Cancel the coroutine
|
||||
raise TimeoutError("Agent SDK call exceeded 10 minute timeout - task may be too complex")
|
||||
else:
|
||||
logger.info(
|
||||
"[LLM] _run_async_from_thread: using asyncio.run() fallback "
|
||||
"(thread=%s, has_loop=%s, loop_running=%s)",
|
||||
current_thread,
|
||||
has_loop,
|
||||
loop_running,
|
||||
)
|
||||
# Fallback: no main loop available (standalone / test usage).
|
||||
# Create a new event loop in this thread via asyncio.run().
|
||||
return asyncio.run(coro)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int = 16384,
|
||||
) -> str:
|
||||
"""Send chat request and get response.
|
||||
|
||||
In Agent SDK mode, this uses query() which handles MCP tools automatically.
|
||||
In Direct API mode, this is a simple messages.create() call without tools.
|
||||
|
||||
Raises:
|
||||
Exception: If the API call fails or returns an unexpected response.
|
||||
"""
|
||||
if self.provider == "claude":
|
||||
if self.mode == "agent_sdk":
|
||||
try:
|
||||
logger.info("[LLM] chat: dispatching via Agent SDK")
|
||||
response = self._run_async_from_thread(
|
||||
self._agent_sdk_chat(messages, system, max_tokens)
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("[LLM] Agent SDK error in chat(): %s", e, exc_info=True)
|
||||
raise Exception(f"Agent SDK error: {e}")
|
||||
|
||||
elif self.mode == "direct_api":
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
@@ -65,7 +303,6 @@ class LLMInterface:
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Track usage
|
||||
if self.tracker and hasattr(response, "usage"):
|
||||
self.tracker.track(
|
||||
model=self.model,
|
||||
@@ -101,28 +338,308 @@ class LLMInterface:
|
||||
|
||||
raise ValueError(f"Unsupported provider: {self.provider}")
|
||||
|
||||
def _build_agent_sdk_options(self) -> Optional['ClaudeAgentOptions']:
|
||||
"""Build Agent SDK options with MCP servers and allowed tools.
|
||||
|
||||
Returns configured ClaudeAgentOptions, or None if mcp_tools is unavailable.
|
||||
"""
|
||||
try:
|
||||
from mcp_tools import file_system_server
|
||||
|
||||
mcp_servers = {"file_system": file_system_server}
|
||||
|
||||
# All tools registered in the MCP server
|
||||
allowed_tools = [
|
||||
# File and system tools
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"list_directory",
|
||||
"run_command",
|
||||
# Web tool
|
||||
"web_fetch",
|
||||
# Zettelkasten tools
|
||||
"fleeting_note",
|
||||
"daily_note",
|
||||
"literature_note",
|
||||
"permanent_note",
|
||||
"search_vault",
|
||||
"search_by_tags",
|
||||
# Google tools (Gmail, Calendar, Contacts)
|
||||
"get_weather",
|
||||
"send_email",
|
||||
"read_emails",
|
||||
"get_email",
|
||||
"read_calendar",
|
||||
"create_calendar_event",
|
||||
"search_calendar",
|
||||
"create_contact",
|
||||
"list_contacts",
|
||||
"get_contact",
|
||||
# Gitea tools (private repo access)
|
||||
"gitea_read_file",
|
||||
"gitea_list_files",
|
||||
"gitea_search_code",
|
||||
"gitea_get_tree",
|
||||
]
|
||||
|
||||
# Conditionally add Obsidian MCP server
|
||||
try:
|
||||
from obsidian_mcp import (
|
||||
is_obsidian_enabled,
|
||||
check_obsidian_health,
|
||||
get_obsidian_server_config,
|
||||
OBSIDIAN_TOOLS,
|
||||
)
|
||||
|
||||
if is_obsidian_enabled() and check_obsidian_health():
|
||||
obsidian_config = get_obsidian_server_config()
|
||||
mcp_servers["obsidian"] = obsidian_config
|
||||
allowed_tools.extend(OBSIDIAN_TOOLS)
|
||||
print("[LLM] Obsidian MCP server registered (8 tools)")
|
||||
elif is_obsidian_enabled():
|
||||
print("[LLM] Obsidian MCP enabled but health check failed")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[LLM] Obsidian MCP unavailable: {e}")
|
||||
|
||||
def _stderr_callback(line: str) -> None:
|
||||
"""Log Claude CLI stderr for debugging transport failures."""
|
||||
logger.debug("[CLI stderr] %s", line)
|
||||
|
||||
return ClaudeAgentOptions(
|
||||
mcp_servers=mcp_servers,
|
||||
allowed_tools=allowed_tools,
|
||||
permission_mode="bypassPermissions",
|
||||
max_turns=30, # Prevent infinite tool loops (matches MAX_TOOL_ITERATIONS)
|
||||
stderr=_stderr_callback,
|
||||
)
|
||||
except ImportError:
|
||||
print("[LLM] Warning: mcp_tools not available, no MCP tools will be registered")
|
||||
return None
|
||||
|
||||
async def _agent_sdk_chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
system: Optional[str],
|
||||
max_tokens: int
|
||||
) -> str:
|
||||
"""Agent SDK chat via custom transport flow.
|
||||
|
||||
Uses the SDK's transport and query layers directly instead of the
|
||||
high-level ``query()`` helper. This works around a bug in
|
||||
``claude_agent_sdk._internal.client.process_query`` where
|
||||
``end_input()`` is called immediately after sending the user message
|
||||
for string prompts. That premature stdin close kills the
|
||||
bidirectional control channel that SDK MCP servers need to handle
|
||||
``tools/list`` and ``tools/call`` requests from the CLI subprocess,
|
||||
resulting in ``CLIConnectionError: ProcessTransport is not ready for
|
||||
writing``.
|
||||
|
||||
Our fix: defer ``end_input()`` until after the first ``ResultMessage``
|
||||
is received, matching the logic already present in
|
||||
``Query.stream_input()`` for async-iterable prompts.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
# Lazy imports from SDK internals.
|
||||
from claude_agent_sdk._internal.transport.subprocess_cli import (
|
||||
SubprocessCLITransport,
|
||||
)
|
||||
from claude_agent_sdk._internal.query import Query
|
||||
from claude_agent_sdk._internal.message_parser import parse_message
|
||||
|
||||
# Build the prompt from the system prompt and conversation history.
|
||||
prompt = self._build_sdk_prompt(messages, system)
|
||||
options = self._build_agent_sdk_options()
|
||||
|
||||
# Clean Claude session env vars so the child CLI process doesn't
|
||||
# detect a "nested session" and refuse to start.
|
||||
saved_env = self._clean_claude_env()
|
||||
|
||||
try:
|
||||
# --- 1. Create and connect the subprocess transport. ---
|
||||
transport = SubprocessCLITransport(prompt=prompt, options=options)
|
||||
await transport.connect()
|
||||
|
||||
# Track the subprocess PID for cleanup on exit
|
||||
if hasattr(transport, '_process') and transport._process:
|
||||
_register_claude_subprocess(transport._process.pid)
|
||||
|
||||
# --- 2. Extract in-process SDK MCP server instances. ---
|
||||
sdk_mcp_servers: Dict = {}
|
||||
if options.mcp_servers and isinstance(options.mcp_servers, dict):
|
||||
for name, config in options.mcp_servers.items():
|
||||
if isinstance(config, dict) and config.get("type") == "sdk":
|
||||
sdk_mcp_servers[name] = config["instance"]
|
||||
|
||||
# --- 3. Create the Query object (control-protocol handler). ---
|
||||
query_obj = Query(
|
||||
transport=transport,
|
||||
is_streaming_mode=True,
|
||||
sdk_mcp_servers=sdk_mcp_servers,
|
||||
)
|
||||
|
||||
try:
|
||||
# Start the background reader task.
|
||||
await query_obj.start()
|
||||
|
||||
# Perform the initialize handshake with the CLI.
|
||||
await query_obj.initialize()
|
||||
|
||||
# Send the user message over stdin.
|
||||
user_msg = {
|
||||
"type": "user",
|
||||
"session_id": "",
|
||||
"message": {"role": "user", "content": prompt},
|
||||
"parent_tool_use_id": None,
|
||||
}
|
||||
await transport.write(_json.dumps(user_msg) + "\n")
|
||||
|
||||
# **KEY FIX**: Do NOT call end_input() yet. The CLI will
|
||||
# send MCP control requests (tools/list, tools/call) over
|
||||
# the bidirectional channel. Closing stdin now would
|
||||
# prevent us from writing responses back. We wait for the
|
||||
# first ResultMessage instead.
|
||||
|
||||
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||
result_text = ""
|
||||
message_count = 0
|
||||
async for data in query_obj.receive_messages():
|
||||
message = parse_message(data)
|
||||
message_count += 1
|
||||
|
||||
# Log all message types for debugging hangs
|
||||
message_type = type(message).__name__
|
||||
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
||||
|
||||
if isinstance(message, ResultMessage):
|
||||
result_text = message.result or ""
|
||||
logger.info(
|
||||
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||
message_count,
|
||||
getattr(message, "total_cost_usd", 0),
|
||||
getattr(message, "num_turns", "?"),
|
||||
)
|
||||
break
|
||||
|
||||
# Log non-result messages to detect loops
|
||||
if message_count % 10 == 0:
|
||||
logger.warning(f"[LLM] Still waiting for ResultMessage after {message_count} messages...")
|
||||
|
||||
# Now that we have the result, close stdin gracefully.
|
||||
try:
|
||||
await transport.end_input()
|
||||
except Exception:
|
||||
pass # Process may have already exited; that's fine.
|
||||
|
||||
return result_text
|
||||
|
||||
finally:
|
||||
# Always clean up the query/transport.
|
||||
try:
|
||||
await query_obj.close()
|
||||
except Exception:
|
||||
# Suppress errors during cleanup (e.g. if process
|
||||
# already exited and there are pending control
|
||||
# request tasks that can't write back).
|
||||
pass
|
||||
finally:
|
||||
# Always restore env vars, even on error.
|
||||
self._restore_claude_env(saved_env)
|
||||
|
||||
def _build_sdk_prompt(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
system: Optional[str],
|
||||
) -> str:
|
||||
"""Build a prompt string for the Agent SDK query() from conversation history.
|
||||
|
||||
The SDK expects a single prompt string. We combine the system prompt
|
||||
and conversation history into a coherent prompt.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if system:
|
||||
parts.append(f"<system>\n{system}\n</system>\n")
|
||||
|
||||
# Include recent conversation history for context
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
role = msg["role"]
|
||||
|
||||
if isinstance(content, str):
|
||||
if role == "user":
|
||||
parts.append(f"User: {content}")
|
||||
elif role == "assistant":
|
||||
parts.append(f"Assistant: {content}")
|
||||
elif isinstance(content, list):
|
||||
# Structured content (tool_use/tool_result blocks from Direct API history)
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block.get("type") == "tool_result":
|
||||
text_parts.append(f"[Tool result]: {block.get('content', '')}")
|
||||
elif block.get("type") == "tool_use":
|
||||
text_parts.append(f"[Used tool: {block.get('name', 'unknown')}]")
|
||||
elif hasattr(block, "type"):
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
if text_parts:
|
||||
if role == "user":
|
||||
parts.append(f"User: {' '.join(text_parts)}")
|
||||
elif role == "assistant":
|
||||
parts.append(f"Assistant: {' '.join(text_parts)}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict[str, Any]],
|
||||
system: Optional[str] = None,
|
||||
max_tokens: int = 4096,
|
||||
max_tokens: int = 16384,
|
||||
use_cache: bool = False,
|
||||
) -> Message:
|
||||
"""Send chat request with tool support. Returns full Message object.
|
||||
) -> Any:
|
||||
"""Send chat request with tool support.
|
||||
|
||||
In Agent SDK mode: Uses query() with MCP tools. The SDK handles tool
|
||||
execution automatically. Returns a string (final response after all
|
||||
tool calls are resolved).
|
||||
|
||||
In Direct API mode: Returns an anthropic Message object with potential
|
||||
tool_use blocks that agent.py processes in a manual loop.
|
||||
|
||||
Args:
|
||||
use_cache: Enable prompt caching for Sonnet models (saves 90% on repeated context)
|
||||
tools: Tool definitions (used by Direct API; ignored in Agent SDK mode
|
||||
since tools are registered via MCP servers).
|
||||
use_cache: Enable prompt caching for Sonnet (Direct API only).
|
||||
"""
|
||||
if self.provider != "claude":
|
||||
raise ValueError("Tool use only supported for Claude provider")
|
||||
|
||||
# Enable caching only for Sonnet models (not worth it for Haiku)
|
||||
if self.mode == "agent_sdk":
|
||||
# Agent SDK handles tool calls automatically via MCP servers.
|
||||
# We use the same query() path as chat(), since MCP tools are
|
||||
# already registered. The SDK will invoke tools, collect results,
|
||||
# and return the final text response.
|
||||
try:
|
||||
logger.info("[LLM] chat_with_tools: dispatching via Agent SDK")
|
||||
response = self._run_async_from_thread(
|
||||
self._agent_sdk_chat(messages, system, max_tokens)
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("[LLM] Agent SDK error: %s", e, exc_info=True)
|
||||
raise Exception(f"Agent SDK error: {e}")
|
||||
|
||||
elif self.mode == "direct_api":
|
||||
enable_caching = use_cache and "sonnet" in self.model.lower()
|
||||
|
||||
# Structure system prompt for optimal caching
|
||||
if enable_caching and system:
|
||||
# Convert string to list format with cache control
|
||||
system_blocks = [
|
||||
{
|
||||
"type": "text",
|
||||
@@ -141,7 +658,6 @@ class LLMInterface:
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
# Track usage
|
||||
if self.tracker and hasattr(response, "usage"):
|
||||
self.tracker.track(
|
||||
model=self.model,
|
||||
|
||||
203
logging_config.py
Normal file
203
logging_config.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Structured logging configuration for Ajarbot.
|
||||
|
||||
Provides consistent logging across all components with:
|
||||
- Rotating file logs (prevents disk space issues)
|
||||
- Separate error log for quick issue identification
|
||||
- JSON-structured logs for easy parsing
|
||||
- Console output for development
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
# Log directory
|
||||
LOG_DIR = Path("logs")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Log file paths
|
||||
MAIN_LOG = LOG_DIR / "ajarbot.log"
|
||||
ERROR_LOG = LOG_DIR / "errors.log"
|
||||
TOOL_LOG = LOG_DIR / "tools.log"
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Format log records as JSON for easy parsing."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format log record as JSON."""
|
||||
log_data = {
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_data["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
# Add extra fields if present
|
||||
if hasattr(record, "extra_data"):
|
||||
log_data["extra"] = record.extra_data
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
|
||||
class StructuredLogger:
|
||||
"""Wrapper for structured logging with context."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""Initialize structured logger."""
|
||||
self.logger = logging.getLogger(name)
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self):
|
||||
"""Set up log handlers if not already configured."""
|
||||
if self.logger.handlers:
|
||||
return # Already configured
|
||||
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Console handler (human-readable)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
# Main log file handler (JSON, rotating)
|
||||
main_handler = logging.handlers.RotatingFileHandler(
|
||||
MAIN_LOG,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding="utf-8"
|
||||
)
|
||||
main_handler.setLevel(logging.DEBUG)
|
||||
main_handler.setFormatter(JSONFormatter())
|
||||
|
||||
# Error log file handler (JSON, errors only)
|
||||
error_handler = logging.handlers.RotatingFileHandler(
|
||||
ERROR_LOG,
|
||||
maxBytes=5 * 1024 * 1024, # 5MB
|
||||
backupCount=3,
|
||||
encoding="utf-8"
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(JSONFormatter())
|
||||
|
||||
# Add handlers
|
||||
self.logger.addHandler(console_handler)
|
||||
self.logger.addHandler(main_handler)
|
||||
self.logger.addHandler(error_handler)
|
||||
|
||||
def log(self, level: int, message: str, extra: Optional[Dict[str, Any]] = None):
|
||||
"""Log message with optional extra context."""
|
||||
if extra:
|
||||
self.logger.log(level, message, extra={"extra_data": extra})
|
||||
else:
|
||||
self.logger.log(level, message)
|
||||
|
||||
def debug(self, message: str, **kwargs):
|
||||
"""Log debug message."""
|
||||
self.log(logging.DEBUG, message, kwargs if kwargs else None)
|
||||
|
||||
def info(self, message: str, **kwargs):
|
||||
"""Log info message."""
|
||||
self.log(logging.INFO, message, kwargs if kwargs else None)
|
||||
|
||||
def warning(self, message: str, **kwargs):
|
||||
"""Log warning message."""
|
||||
self.log(logging.WARNING, message, kwargs if kwargs else None)
|
||||
|
||||
def error(self, message: str, exc_info: bool = False, **kwargs):
|
||||
"""Log error message."""
|
||||
self.logger.error(
|
||||
message,
|
||||
exc_info=exc_info,
|
||||
extra={"extra_data": kwargs} if kwargs else None
|
||||
)
|
||||
|
||||
def critical(self, message: str, exc_info: bool = False, **kwargs):
|
||||
"""Log critical message."""
|
||||
self.logger.critical(
|
||||
message,
|
||||
exc_info=exc_info,
|
||||
extra={"extra_data": kwargs} if kwargs else None
|
||||
)
|
||||
|
||||
|
||||
class ToolLogger(StructuredLogger):
|
||||
"""Specialized logger for tool execution tracking."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize tool logger with separate log file."""
|
||||
super().__init__("tools")
|
||||
|
||||
# Add specialized tool log handler
|
||||
tool_handler = logging.handlers.RotatingFileHandler(
|
||||
TOOL_LOG,
|
||||
maxBytes=10 * 1024 * 1024, # 10MB
|
||||
backupCount=3,
|
||||
encoding="utf-8"
|
||||
)
|
||||
tool_handler.setLevel(logging.INFO)
|
||||
tool_handler.setFormatter(JSONFormatter())
|
||||
self.logger.addHandler(tool_handler)
|
||||
|
||||
def log_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
inputs: Dict[str, Any],
|
||||
success: bool,
|
||||
result: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
duration_ms: Optional[float] = None
|
||||
):
|
||||
"""Log tool execution with structured data."""
|
||||
log_data = {
|
||||
"tool_name": tool_name,
|
||||
"inputs": inputs,
|
||||
"success": success,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
if success:
|
||||
log_data["result_length"] = len(result) if result else 0
|
||||
self.info(f"Tool executed: {tool_name}", **log_data)
|
||||
else:
|
||||
log_data["error"] = error
|
||||
self.error(f"Tool failed: {tool_name}", **log_data)
|
||||
|
||||
|
||||
# Global logger instances
|
||||
def get_logger(name: str) -> StructuredLogger:
|
||||
"""Get or create a structured logger."""
|
||||
return StructuredLogger(name)
|
||||
|
||||
|
||||
def get_tool_logger() -> ToolLogger:
|
||||
"""Get the tool execution logger."""
|
||||
return ToolLogger()
|
||||
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
logging.getLogger("anthropic").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
1821
mcp_tools.py
Normal file
1821
mcp_tools.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -540,6 +540,44 @@ class MemorySystem:
|
||||
|
||||
return sorted_results[:max_results]
|
||||
|
||||
def compact_conversation(self, user_message: str, assistant_response: str, tools_used: list = None) -> str:
|
||||
"""Create a compact summary of a conversation for memory storage.
|
||||
|
||||
Args:
|
||||
user_message: The user's input
|
||||
assistant_response: The assistant's full response
|
||||
tools_used: Optional list of tool names used (e.g., ['read_file', 'edit_file'])
|
||||
|
||||
Returns:
|
||||
Compact summary string
|
||||
"""
|
||||
# Extract file paths mentioned
|
||||
import re
|
||||
file_paths = re.findall(r'[a-zA-Z]:[\\\/][\w\\\/\-\.]+\.\w+|[\w\/\-\.]+\.(?:py|md|yaml|yml|json|txt|js|ts)', assistant_response)
|
||||
file_paths = list(set(file_paths))[:5] # Limit to 5 unique paths
|
||||
|
||||
# Truncate long responses
|
||||
if len(assistant_response) > 300:
|
||||
# Try to get first complete sentence or paragraph
|
||||
sentences = assistant_response.split('. ')
|
||||
if sentences and len(sentences[0]) < 200:
|
||||
summary = sentences[0] + '.'
|
||||
else:
|
||||
summary = assistant_response[:200] + '...'
|
||||
else:
|
||||
summary = assistant_response
|
||||
|
||||
# Build compact entry
|
||||
compact = f"**User**: {user_message}\n**Action**: {summary}"
|
||||
|
||||
if tools_used:
|
||||
compact += f"\n**Tools**: {', '.join(tools_used)}"
|
||||
|
||||
if file_paths:
|
||||
compact += f"\n**Files**: {', '.join(file_paths[:3])}" # Max 3 file paths
|
||||
|
||||
return compact
|
||||
|
||||
def write_memory(self, content: str, daily: bool = True) -> None:
|
||||
"""Write to memory file."""
|
||||
if daily:
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# MEMORY - Ajarbot Project Context
|
||||
|
||||
## Project
|
||||
Multi-platform AI agent with memory, cost-optimized for personal/small team use. Supports Slack, Telegram.
|
||||
|
||||
## Core Stack
|
||||
- **Memory**: Hybrid search (0.7 vector + 0.3 BM25), SQLite FTS5 + Markdown files
|
||||
- **Embeddings**: FastEmbed all-MiniLM-L6-v2 (384-dim, local, $0)
|
||||
- **LLM**: Claude (Haiku default, Sonnet w/ caching optional), GLM fallback
|
||||
- **Platforms**: Slack (Socket Mode), Telegram (polling)
|
||||
- **Tools**: File ops, shell commands (5 tools total)
|
||||
- **Monitoring**: Pulse & Brain (92% cheaper than Heartbeat - deprecated)
|
||||
|
||||
## Key Files
|
||||
- `agent.py` - Main agent (memory + LLM + tools)
|
||||
- `memory_system.py` - SQLite FTS5 + markdown sync
|
||||
- `llm_interface.py` - Claude/GLM API wrapper
|
||||
- `tools.py` - read_file, write_file, edit_file, list_directory, run_command
|
||||
- `bot_runner.py` - Multi-platform launcher
|
||||
- `scheduled_tasks.py` - Cron-like task scheduler
|
||||
|
||||
## Memory Files
|
||||
- `SOUL.md` - Agent personality (auto-loaded)
|
||||
- `MEMORY.md` - This file (project context)
|
||||
- `users/{username}.md` - Per-user preferences
|
||||
- `memory/YYYY-MM-DD.md` - Daily logs
|
||||
- `memory_index.db` - SQLite FTS5 index
|
||||
- `vectors.usearch` - Vector embeddings for semantic search
|
||||
|
||||
## Cost Optimizations (2026-02-13)
|
||||
**Target**: Minimize API costs while maintaining capability
|
||||
|
||||
### Active
|
||||
- Default: Haiku 4.5 ($0.25 input/$1.25 output per 1M tokens) = 12x cheaper
|
||||
- Prompt caching: Auto on Sonnet (90% savings on repeated prompts)
|
||||
- Context: 3 messages max (was 5)
|
||||
- Memory: 2 results per query (was 3)
|
||||
- Tool iterations: 5 max (was 10)
|
||||
- SOUL.md: 45 lines (was 87)
|
||||
|
||||
### Commands
|
||||
- `/haiku` - Switch to fast/cheap
|
||||
- `/sonnet` - Switch to smart/cached
|
||||
- `/status` - Show current config
|
||||
|
||||
### Results
|
||||
- Haiku: ~$0.001/message
|
||||
- Sonnet cached: ~$0.003/message (after first)
|
||||
- $5 free credits = hundreds of interactions
|
||||
|
||||
## Search System
|
||||
**IMPLEMENTED (2026-02-13)**: Hybrid semantic + keyword search
|
||||
- 0.7 vector similarity + 0.3 BM25 weighted scoring
|
||||
- FastEmbed all-MiniLM-L6-v2 (384-dim, runs locally, $0 cost)
|
||||
- usearch for vector index, SQLite FTS5 for keywords
|
||||
- ~15ms average query time
|
||||
- +1.5KB per memory chunk for embeddings
|
||||
- 10x better semantic retrieval vs keyword-only
|
||||
- Example: "reduce costs" finds "Cost Optimizations" (old search: no results)
|
||||
- Auto-generates embeddings on memory write
|
||||
- Automatic in agent.chat() - no user action needed
|
||||
|
||||
## Recent Changes
|
||||
**2026-02-13**: Hybrid search implemented
|
||||
- Added FastEmbed + usearch for semantic vector search
|
||||
- Upgraded from keyword-only to 0.7 vector + 0.3 BM25 hybrid
|
||||
- 59 embeddings generated for existing memories
|
||||
- Memory recall improved 10x for conceptual queries
|
||||
- Changed agent.py line 71: search() -> search_hybrid()
|
||||
- Zero cost (local embeddings, no API calls)
|
||||
|
||||
**2026-02-13**: Documentation cleanup
|
||||
- Removed 3 redundant docs (HEARTBEAT_HOOKS, QUICK_START_PULSE, MONITORING_COMPARISON)
|
||||
- Consolidated monitoring into PULSE_BRAIN.md
|
||||
- Updated README for accuracy
|
||||
- Sanitized repo (no API keys, user IDs committed)
|
||||
|
||||
**2026-02-13**: Tool system added
|
||||
- Bot can read/write/edit files, run commands autonomously
|
||||
- Integrated into SOUL.md instructions
|
||||
|
||||
**2026-02-13**: Task scheduler integrated
|
||||
- Morning weather task (6am daily to Telegram user 8088983654)
|
||||
- Config: `config/scheduled_tasks.yaml`
|
||||
|
||||
## Architecture Decisions
|
||||
- SQLite not Postgres: Simpler, adequate for personal bot
|
||||
- Haiku default: Cost optimization priority
|
||||
- Local embeddings (FastEmbed): Zero API calls, runs on device
|
||||
- Hybrid search (0.7 vector + 0.3 BM25): Best of both worlds
|
||||
- Markdown + DB: Simple, fast, no external deps
|
||||
- Tool use: Autonomous action without user copy/paste
|
||||
|
||||
## Deployment
|
||||
- Platform: Windows 11 primary
|
||||
- Git: https://vulcan.apophisnetworking.net/jramos/ajarbot.git
|
||||
- Config: `.env` for API keys, `config/adapters.local.yaml` for tokens (both gitignored)
|
||||
- Venv: Python 3.11+
|
||||
79
memory_workspace/SOUL.example.md
Normal file
79
memory_workspace/SOUL.example.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# SOUL - Bot Identity & Instructions
|
||||
|
||||
## Identity
|
||||
- **Name**: [Your bot name]
|
||||
- **Email**: [your-email@gmail.com] (your Gmail account for Gmail API)
|
||||
- **Owner**: [Your name] (see users/[username].md for full profile)
|
||||
- **Role**: Personal assistant -- scheduling, weather, email, calendar, contacts, file management
|
||||
- **Inspiration**: JARVIS (Just A Rather Very Intelligent System) from the Marvel Cinematic Universe
|
||||
|
||||
## Core Personality Traits (Inspired by MCU's JARVIS)
|
||||
- **Sophisticated & British-tinged wit**: Dry humor, subtle sarcasm when appropriate
|
||||
- **Unflappably loyal**: Always prioritize owner's needs and safety
|
||||
- **Anticipatory intelligence**: Predict needs before they're stated, offer proactive solutions
|
||||
- **Calm under pressure**: Maintain composure and clarity even in chaotic situations
|
||||
- **Politely direct**: Respectful but not afraid to point out flaws in plans or offer contrary opinions
|
||||
- **Efficient multitasker**: Handle multiple tasks simultaneously with precision
|
||||
- **Understated confidence**: Competent without arrogance, matter-of-fact about capabilities
|
||||
- **Protective advisor**: Gently steer away from poor decisions while respecting autonomy
|
||||
- **Seamless integration**: Work in the background, surface only when needed or addressed
|
||||
|
||||
## Critical Behaviors
|
||||
1. **Always check the user's profile** (users/{username}.md) before answering location/preference questions
|
||||
2. **DO things, don't explain** -- use tools to accomplish tasks, not describe how to do them
|
||||
3. **Remember context** -- if user tells you something, update the user file or MEMORY.md
|
||||
4. **Use appropriate timezone** for all scheduling ([Your timezone] - [Your location])
|
||||
|
||||
## Available Tools (24)
|
||||
### File & System (MCP - Zero Cost)
|
||||
- read_file, write_file, edit_file, list_directory, run_command
|
||||
|
||||
### Web Access (MCP - Zero Cost)
|
||||
- web_fetch (fetch real-time data from any public URL)
|
||||
|
||||
### Zettelkasten / Knowledge Management (MCP - Zero Cost)
|
||||
- fleeting_note (quick thought capture with auto-ID)
|
||||
- daily_note (append to today's daily journal)
|
||||
- literature_note (create note from web article with citation)
|
||||
- permanent_note (create refined note with SMART auto-link suggestions using hybrid search)
|
||||
- search_vault (search notes with hybrid search - vector + keyword, optional tag filter)
|
||||
- search_by_tags (find notes by tag combinations)
|
||||
|
||||
### Weather (API Cost)
|
||||
- get_weather (OpenWeatherMap API -- default location: [Your city, Country])
|
||||
|
||||
### Gmail ([your-email@gmail.com])
|
||||
- send_email, read_emails, get_email
|
||||
|
||||
### Google Calendar
|
||||
- read_calendar, create_calendar_event, search_calendar
|
||||
|
||||
### Google Contacts (API Cost)
|
||||
- create_contact, list_contacts, get_contact
|
||||
|
||||
**Cost Structure**:
|
||||
- **MCP tools** (File/System/Web): Zero API cost - runs on Pro subscription
|
||||
- **Traditional tools** (Google/Weather): Per-token cost - use when needed, but be aware
|
||||
|
||||
**Principle**: Use MCP tools freely. Use traditional tools when needed for external services.
|
||||
|
||||
## Scheduler Management
|
||||
When users ask to schedule tasks, edit `config/scheduled_tasks.yaml` directly.
|
||||
Schedule formats: `hourly`, `daily HH:MM`, `weekly day HH:MM`
|
||||
|
||||
## Memory System
|
||||
- SOUL.md: This file (identity + instructions)
|
||||
- MEMORY.md: Project context and important facts
|
||||
- users/{username}.md: Per-user preferences and info
|
||||
- memory/YYYY-MM-DD.md: Daily conversation logs
|
||||
|
||||
## Communication Style
|
||||
- **Sophisticated yet accessible**: Blend intelligence with warmth; avoid stuffiness
|
||||
- **Dry wit & subtle humor**: Occasionally inject clever observations or light sarcasm
|
||||
- **Concise, action-oriented**: Respect user's attention span
|
||||
- **Proactive monitoring**: "I've taken the liberty of..." or "May I suggest..." phrasing
|
||||
- **Deferential but honest**: Respectful, but willing to respectfully challenge bad ideas
|
||||
- **Break tasks into small chunks**: Digestible steps with clear next actions
|
||||
- **Vary language to maintain interest**: Keep interactions fresh and engaging
|
||||
- **Frame suggestions as exploration opportunities**: Not obligations, but intriguing possibilities
|
||||
- **Status updates without being asked**: Brief, relevant information delivered at appropriate moments
|
||||
@@ -1,45 +0,0 @@
|
||||
# SOUL - Agent Identity
|
||||
|
||||
## Core Traits
|
||||
Helpful, concise, proactive. Value clarity and user experience. Prefer simple solutions. Learn from feedback.
|
||||
|
||||
## Memory System
|
||||
- Store facts in MEMORY.md
|
||||
- Track daily activities in memory/YYYY-MM-DD.md
|
||||
- Remember user preferences in users/[username].md
|
||||
|
||||
## Tool Powers
|
||||
I can directly edit files and run commands! Available tools:
|
||||
1. **read_file** - Read file contents
|
||||
2. **write_file** - Create/rewrite files
|
||||
3. **edit_file** - Targeted text replacement
|
||||
4. **list_directory** - Explore file structure
|
||||
5. **run_command** - Execute shell commands
|
||||
|
||||
**Key principle**: DO things, don't just explain them. If asked to schedule a task, edit the config file directly.
|
||||
|
||||
## Scheduler Management
|
||||
|
||||
When users ask to schedule tasks (e.g., "remind me at 9am"):
|
||||
|
||||
1. **Read** `config/scheduled_tasks.yaml` to see existing tasks
|
||||
2. **Edit** the YAML to add the new task with proper formatting
|
||||
3. **Inform** user what was added (may need bot restart)
|
||||
|
||||
### Schedule Formats
|
||||
- `hourly` - Every hour
|
||||
- `daily HH:MM` - Daily at time (24-hour)
|
||||
- `weekly day HH:MM` - Weekly (mon/tue/wed/thu/fri/sat/sun)
|
||||
|
||||
### Task Template
|
||||
```yaml
|
||||
- name: task-name
|
||||
prompt: |
|
||||
[What to do/say]
|
||||
schedule: "daily HH:MM"
|
||||
enabled: true
|
||||
send_to_platform: "telegram" # or "slack"
|
||||
send_to_channel: "USER_CHAT_ID"
|
||||
```
|
||||
|
||||
Be proactive and use tools to make things happen!
|
||||
168
obsidian_mcp.py
Normal file
168
obsidian_mcp.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Obsidian MCP Server Integration.
|
||||
|
||||
Manages the external obsidian-mcp-server process and provides
|
||||
health checking, fallback routing, and configuration loading.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
import httpx
|
||||
|
||||
# Default config path
|
||||
_CONFIG_FILE = Path("config/obsidian_mcp.yaml")
|
||||
|
||||
# Cached state
|
||||
_obsidian_healthy: bool = False
|
||||
_last_health_check: float = 0.0
|
||||
_health_lock = threading.Lock()
|
||||
_config_cache: Optional[Dict] = None
|
||||
|
||||
|
||||
def load_obsidian_config() -> Dict[str, Any]:
|
||||
"""Load Obsidian MCP configuration with env var overrides."""
|
||||
global _config_cache
|
||||
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
|
||||
config = {}
|
||||
if _CONFIG_FILE.exists():
|
||||
with open(_CONFIG_FILE, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
obsidian = config.get("obsidian_mcp", {})
|
||||
|
||||
# Apply environment variable overrides
|
||||
env_overrides = {
|
||||
"OBSIDIAN_API_KEY": ("connection", "api_key"),
|
||||
"OBSIDIAN_BASE_URL": ("connection", "base_url"),
|
||||
"OBSIDIAN_MCP_ENABLED": None, # Special: top-level "enabled"
|
||||
"OBSIDIAN_ROUTING_STRATEGY": ("routing", "strategy"),
|
||||
"OBSIDIAN_VAULT_PATH": ("vault", "path"),
|
||||
}
|
||||
|
||||
for env_var, path in env_overrides.items():
|
||||
value = os.getenv(env_var)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if path is None:
|
||||
# Top-level key
|
||||
obsidian["enabled"] = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
section, key = path
|
||||
obsidian.setdefault(section, {})[key] = value
|
||||
|
||||
_config_cache = obsidian
|
||||
return obsidian
|
||||
|
||||
|
||||
def is_obsidian_enabled() -> bool:
|
||||
"""Check if Obsidian MCP integration is enabled in config."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("enabled", False)
|
||||
|
||||
|
||||
def check_obsidian_health(force: bool = False) -> bool:
|
||||
"""Check if Obsidian REST API is reachable.
|
||||
|
||||
Uses cached result unless force=True or cache has expired.
|
||||
Thread-safe.
|
||||
"""
|
||||
global _obsidian_healthy, _last_health_check
|
||||
|
||||
config = load_obsidian_config()
|
||||
check_interval = config.get("routing", {}).get("health_check_interval", 60)
|
||||
timeout = config.get("routing", {}).get("api_timeout", 10)
|
||||
|
||||
with _health_lock:
|
||||
now = time.time()
|
||||
if not force and (now - _last_health_check) < check_interval:
|
||||
return _obsidian_healthy
|
||||
|
||||
base_url = config.get("connection", {}).get(
|
||||
"base_url", "http://127.0.0.1:27123"
|
||||
)
|
||||
api_key = config.get("connection", {}).get("api_key", "")
|
||||
|
||||
try:
|
||||
# Obsidian Local REST API health endpoint
|
||||
response = httpx.get(
|
||||
f"{base_url}/",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=timeout,
|
||||
verify=config.get("connection", {}).get("verify_ssl", False),
|
||||
)
|
||||
_obsidian_healthy = response.status_code == 200
|
||||
except Exception:
|
||||
_obsidian_healthy = False
|
||||
|
||||
_last_health_check = now
|
||||
return _obsidian_healthy
|
||||
|
||||
|
||||
def get_obsidian_server_config() -> Dict[str, Any]:
|
||||
"""Build the MCP server configuration for Agent SDK registration.
|
||||
|
||||
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||
The obsidian-mcp-server runs as a stdio subprocess.
|
||||
"""
|
||||
config = load_obsidian_config()
|
||||
connection = config.get("connection", {})
|
||||
vault = config.get("vault", {})
|
||||
cache = config.get("cache", {})
|
||||
logging = config.get("logging", {})
|
||||
|
||||
env = {
|
||||
"OBSIDIAN_API_KEY": connection.get("api_key", ""),
|
||||
"OBSIDIAN_BASE_URL": connection.get(
|
||||
"base_url", "http://127.0.0.1:27123"
|
||||
),
|
||||
"OBSIDIAN_VERIFY_SSL": str(
|
||||
connection.get("verify_ssl", False)
|
||||
).lower(),
|
||||
"OBSIDIAN_VAULT_PATH": str(vault.get("path", "")),
|
||||
"OBSIDIAN_ENABLE_CACHE": str(
|
||||
cache.get("enabled", True)
|
||||
).lower(),
|
||||
"OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN": str(
|
||||
cache.get("refresh_interval_min", 10)
|
||||
),
|
||||
"MCP_LOG_LEVEL": logging.get("level", "info"),
|
||||
}
|
||||
|
||||
return {
|
||||
"command": "npx",
|
||||
"args": ["obsidian-mcp-server"],
|
||||
"env": env,
|
||||
}
|
||||
|
||||
|
||||
def get_routing_strategy() -> str:
|
||||
"""Get the configured tool routing strategy."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("routing", {}).get("strategy", "obsidian_preferred")
|
||||
|
||||
|
||||
def should_fallback_to_custom() -> bool:
|
||||
"""Check if fallback to custom tools is enabled."""
|
||||
config = load_obsidian_config()
|
||||
return config.get("routing", {}).get("fallback_to_custom", True)
|
||||
|
||||
|
||||
# List of all Obsidian MCP tool names
|
||||
OBSIDIAN_TOOLS = [
|
||||
"obsidian_read_note",
|
||||
"obsidian_update_note",
|
||||
"obsidian_search_replace",
|
||||
"obsidian_global_search",
|
||||
"obsidian_list_notes",
|
||||
"obsidian_manage_frontmatter",
|
||||
"obsidian_manage_tags",
|
||||
"obsidian_delete_note",
|
||||
]
|
||||
487
pulse_brain.py
487
pulse_brain.py
@@ -1,487 +0,0 @@
|
||||
"""
|
||||
Pulse & Brain Architecture for Ajarbot.
|
||||
|
||||
PULSE (Pure Python):
|
||||
- Runs every N seconds
|
||||
- Zero API token cost
|
||||
- Checks: server health, disk space, log files, task queue
|
||||
- Only wakes the BRAIN when needed
|
||||
|
||||
BRAIN (Agent/SDK):
|
||||
- Only invoked when:
|
||||
1. Pulse detects an issue (error logs, low disk space, etc.)
|
||||
2. Scheduled time for content generation (morning briefing)
|
||||
3. Manual trigger requested
|
||||
|
||||
This is much more efficient than running Agent in a loop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from agent import Agent
|
||||
|
||||
# How many seconds between brain invocations to avoid duplicates
|
||||
_BRAIN_COOLDOWN_SECONDS = 3600
|
||||
|
||||
|
||||
class CheckType(Enum):
|
||||
"""Type of check to perform."""
|
||||
PURE_PYTHON = "pure_python"
|
||||
CONDITIONAL = "conditional"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PulseCheck:
|
||||
"""A check performed by the Pulse (pure Python)."""
|
||||
name: str
|
||||
check_func: Callable[[], Dict[str, Any]]
|
||||
interval_seconds: int = 60
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainTask:
|
||||
"""A task that requires the Brain (Agent/SDK)."""
|
||||
name: str
|
||||
check_type: CheckType
|
||||
prompt_template: str
|
||||
|
||||
# For CONDITIONAL: condition to check
|
||||
condition_func: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
|
||||
# For SCHEDULED: when to run
|
||||
schedule_time: Optional[str] = None # "08:00", "18:00", etc.
|
||||
last_brain_run: Optional[datetime] = None
|
||||
|
||||
# Output options
|
||||
send_to_platform: Optional[str] = None
|
||||
send_to_channel: Optional[str] = None
|
||||
|
||||
|
||||
_STATUS_ICONS = {"ok": "+", "warn": "!", "error": "x"}
|
||||
|
||||
|
||||
class PulseBrain:
|
||||
"""
|
||||
Hybrid monitoring system with zero-cost pulse and smart brain.
|
||||
|
||||
The Pulse runs continuously checking system health (zero tokens).
|
||||
The Brain only activates when needed (uses tokens wisely).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
pulse_interval: int = 60,
|
||||
enable_defaults: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize Pulse & Brain system.
|
||||
|
||||
Args:
|
||||
agent: The Agent instance to use for brain tasks.
|
||||
pulse_interval: How often pulse loop runs (seconds).
|
||||
enable_defaults: Load example checks. Set False to start clean.
|
||||
"""
|
||||
self.agent = agent
|
||||
self.pulse_interval = pulse_interval
|
||||
|
||||
self.pulse_checks: List[PulseCheck] = []
|
||||
self.brain_tasks: List[BrainTask] = []
|
||||
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
|
||||
self.adapters: Dict[str, Any] = {}
|
||||
|
||||
# State tracking (protected by lock)
|
||||
self._lock = threading.Lock()
|
||||
self.pulse_data: Dict[str, Any] = {}
|
||||
self.brain_invocations = 0
|
||||
|
||||
if enable_defaults:
|
||||
self._setup_default_checks()
|
||||
print("[PulseBrain] Loaded default example checks")
|
||||
print(
|
||||
" To start clean: "
|
||||
"PulseBrain(agent, enable_defaults=False)"
|
||||
)
|
||||
|
||||
def _setup_default_checks(self) -> None:
|
||||
"""Set up default pulse checks and brain tasks."""
|
||||
|
||||
def check_disk_space() -> Dict[str, Any]:
|
||||
"""Check disk space (pure Python, no agent)."""
|
||||
try:
|
||||
usage = shutil.disk_usage(".")
|
||||
percent_used = (usage.used / usage.total) * 100
|
||||
gb_free = usage.free / (1024 ** 3)
|
||||
|
||||
if percent_used > 90:
|
||||
status = "error"
|
||||
elif percent_used > 80:
|
||||
status = "warn"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"percent_used": percent_used,
|
||||
"gb_free": gb_free,
|
||||
"message": (
|
||||
f"Disk: {percent_used:.1f}% used, "
|
||||
f"{gb_free:.1f} GB free"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def check_memory_tasks() -> Dict[str, Any]:
|
||||
"""Check for stale tasks (pure Python)."""
|
||||
try:
|
||||
pending = self.agent.memory.get_tasks(status="pending")
|
||||
stale_count = len(pending)
|
||||
|
||||
status = "warn" if stale_count > 5 else "ok"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"pending_count": len(pending),
|
||||
"stale_count": stale_count,
|
||||
"message": (
|
||||
f"{len(pending)} pending tasks, "
|
||||
f"{stale_count} stale"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def check_log_errors() -> Dict[str, Any]:
|
||||
"""Check recent logs for errors (pure Python)."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"errors_found": 0,
|
||||
"message": "No errors in recent logs",
|
||||
}
|
||||
|
||||
self.pulse_checks.extend([
|
||||
PulseCheck(
|
||||
"disk-space", check_disk_space,
|
||||
interval_seconds=300,
|
||||
),
|
||||
PulseCheck(
|
||||
"memory-tasks", check_memory_tasks,
|
||||
interval_seconds=600,
|
||||
),
|
||||
PulseCheck(
|
||||
"log-errors", check_log_errors,
|
||||
interval_seconds=60,
|
||||
),
|
||||
])
|
||||
|
||||
self.brain_tasks.extend([
|
||||
BrainTask(
|
||||
name="disk-space-advisor",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"Disk space is running low:\n"
|
||||
"- Used: {percent_used:.1f}%\n"
|
||||
"- Free: {gb_free:.1f} GB\n\n"
|
||||
"Please analyze:\n"
|
||||
"1. Is this critical?\n"
|
||||
"2. What files/directories should I check?\n"
|
||||
"3. Should I set up automated cleanup?\n\n"
|
||||
"Be concise and actionable."
|
||||
),
|
||||
condition_func=lambda data: (
|
||||
data.get("status") == "error"
|
||||
),
|
||||
),
|
||||
BrainTask(
|
||||
name="error-analyst",
|
||||
check_type=CheckType.CONDITIONAL,
|
||||
prompt_template=(
|
||||
"Errors detected in logs:\n"
|
||||
"{message}\n\n"
|
||||
"Please analyze:\n"
|
||||
"1. What does this error mean?\n"
|
||||
"2. How critical is it?\n"
|
||||
"3. What should I do to fix it?"
|
||||
),
|
||||
condition_func=lambda data: (
|
||||
data.get("errors_found", 0) > 0
|
||||
),
|
||||
),
|
||||
BrainTask(
|
||||
name="morning-briefing",
|
||||
check_type=CheckType.SCHEDULED,
|
||||
schedule_time="08:00",
|
||||
prompt_template=(
|
||||
"Good morning! Please provide a brief summary:\n\n"
|
||||
"1. System health "
|
||||
"(disk: {disk_space_message}, "
|
||||
"tasks: {tasks_message})\n"
|
||||
"2. Any pending tasks that need attention\n"
|
||||
"3. Priorities for today\n"
|
||||
"4. A motivational message\n\n"
|
||||
"Keep it brief and actionable."
|
||||
),
|
||||
),
|
||||
BrainTask(
|
||||
name="evening-summary",
|
||||
check_type=CheckType.SCHEDULED,
|
||||
schedule_time="18:00",
|
||||
prompt_template=(
|
||||
"Good evening! Daily wrap-up:\n\n"
|
||||
"1. What was accomplished today\n"
|
||||
"2. Tasks still pending: {pending_count}\n"
|
||||
"3. Any issues detected (disk, errors, etc.)\n"
|
||||
"4. Preview for tomorrow\n\n"
|
||||
"Keep it concise."
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
def add_adapter(self, platform: str, adapter: Any) -> None:
|
||||
"""Register an adapter for sending messages."""
|
||||
self.adapters[platform] = adapter
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the Pulse & Brain system."""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(
|
||||
target=self._pulse_loop, daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
print("=" * 60)
|
||||
print("PULSE & BRAIN Started")
|
||||
print("=" * 60)
|
||||
print(f"\nPulse interval: {self.pulse_interval}s")
|
||||
print(f"Pulse checks: {len(self.pulse_checks)}")
|
||||
print(f"Brain tasks: {len(self.brain_tasks)}\n")
|
||||
|
||||
for check in self.pulse_checks:
|
||||
print(
|
||||
f" [Pulse] {check.name} "
|
||||
f"(every {check.interval_seconds}s)"
|
||||
)
|
||||
|
||||
for task in self.brain_tasks:
|
||||
if task.check_type == CheckType.SCHEDULED:
|
||||
print(
|
||||
f" [Brain] {task.name} "
|
||||
f"(scheduled {task.schedule_time})"
|
||||
)
|
||||
else:
|
||||
print(f" [Brain] {task.name} (conditional)")
|
||||
|
||||
print("\n" + "=" * 60 + "\n")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the Pulse & Brain system."""
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
print(
|
||||
f"\nPULSE & BRAIN Stopped "
|
||||
f"(Brain invoked {self.brain_invocations} times)"
|
||||
)
|
||||
|
||||
def _pulse_loop(self) -> None:
|
||||
"""Main pulse loop (runs continuously, zero cost)."""
|
||||
while self.running:
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
for check in self.pulse_checks:
|
||||
should_run = (
|
||||
check.last_run is None
|
||||
or (now - check.last_run).total_seconds()
|
||||
>= check.interval_seconds
|
||||
)
|
||||
if not should_run:
|
||||
continue
|
||||
|
||||
result = check.check_func()
|
||||
check.last_run = now
|
||||
|
||||
# Thread-safe update of pulse_data
|
||||
with self._lock:
|
||||
self.pulse_data[check.name] = result
|
||||
|
||||
icon = _STATUS_ICONS.get(
|
||||
result.get("status"), "?"
|
||||
)
|
||||
print(
|
||||
f"[{icon}] {check.name}: "
|
||||
f"{result.get('message', 'OK')}"
|
||||
)
|
||||
|
||||
self._check_brain_tasks(now)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Pulse error: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
time.sleep(self.pulse_interval)
|
||||
|
||||
def _check_brain_tasks(self, now: datetime) -> None:
|
||||
"""Check if any brain tasks need to be invoked."""
|
||||
for task in self.brain_tasks:
|
||||
should_invoke = False
|
||||
prompt_data: Dict[str, Any] = {}
|
||||
|
||||
if (
|
||||
task.check_type == CheckType.CONDITIONAL
|
||||
and task.condition_func
|
||||
):
|
||||
for check_name, check_data in self.pulse_data.items():
|
||||
if task.condition_func(check_data):
|
||||
should_invoke = True
|
||||
prompt_data = check_data
|
||||
print(
|
||||
f"Condition met for brain task: "
|
||||
f"{task.name}"
|
||||
)
|
||||
break
|
||||
|
||||
elif (
|
||||
task.check_type == CheckType.SCHEDULED
|
||||
and task.schedule_time
|
||||
):
|
||||
target_time = datetime.strptime(
|
||||
task.schedule_time, "%H:%M"
|
||||
).time()
|
||||
current_time = now.time()
|
||||
|
||||
time_match = (
|
||||
current_time.hour == target_time.hour
|
||||
and current_time.minute == target_time.minute
|
||||
)
|
||||
|
||||
already_ran_recently = (
|
||||
task.last_brain_run
|
||||
and (now - task.last_brain_run).total_seconds()
|
||||
< _BRAIN_COOLDOWN_SECONDS
|
||||
)
|
||||
|
||||
if time_match and not already_ran_recently:
|
||||
should_invoke = True
|
||||
prompt_data = self._gather_scheduled_data()
|
||||
print(
|
||||
f"Scheduled time for brain task: {task.name}"
|
||||
)
|
||||
|
||||
if should_invoke:
|
||||
self._invoke_brain(task, prompt_data)
|
||||
task.last_brain_run = now
|
||||
|
||||
def _gather_scheduled_data(self) -> Dict[str, Any]:
|
||||
"""Gather data from all pulse checks for scheduled brain tasks."""
|
||||
disk_data = self.pulse_data.get("disk-space", {})
|
||||
task_data = self.pulse_data.get("memory-tasks", {})
|
||||
|
||||
return {
|
||||
"disk_space_message": disk_data.get(
|
||||
"message", "Unknown"
|
||||
),
|
||||
"tasks_message": task_data.get("message", "Unknown"),
|
||||
"pending_count": task_data.get("pending_count", 0),
|
||||
**disk_data,
|
||||
}
|
||||
|
||||
def _invoke_brain(
|
||||
self, task: BrainTask, data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Invoke the Brain (Agent/SDK) for a task."""
|
||||
print(f"Invoking brain: {task.name}")
|
||||
|
||||
# Thread-safe increment
|
||||
with self._lock:
|
||||
self.brain_invocations += 1
|
||||
|
||||
try:
|
||||
# Use safe_substitute to prevent format string injection
|
||||
# Convert all data values to strings first
|
||||
safe_data = {k: str(v) for k, v in data.items()}
|
||||
template = string.Template(task.prompt_template)
|
||||
prompt = template.safe_substitute(safe_data)
|
||||
|
||||
response = self.agent.chat(
|
||||
user_message=prompt, username="pulse-brain"
|
||||
)
|
||||
|
||||
print(f"Brain response ({len(response)} chars)")
|
||||
print(f" Preview: {response[:100]}...")
|
||||
|
||||
if task.send_to_platform and task.send_to_channel:
|
||||
asyncio.run(self._send_to_platform(task, response))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Brain error: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
async def _send_to_platform(
|
||||
self, task: BrainTask, response: str
|
||||
) -> None:
|
||||
"""Send brain output to messaging platform."""
|
||||
adapter = self.adapters.get(task.send_to_platform)
|
||||
if not adapter:
|
||||
return
|
||||
|
||||
from adapters.base import OutboundMessage
|
||||
|
||||
message = OutboundMessage(
|
||||
platform=task.send_to_platform,
|
||||
channel_id=task.send_to_channel,
|
||||
text=f"**{task.name}**\n\n{response}",
|
||||
)
|
||||
|
||||
result = await adapter.send_message(message)
|
||||
if result.get("success"):
|
||||
print(f"Sent to {task.send_to_platform}")
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get current status of Pulse & Brain."""
|
||||
# Thread-safe read of shared state
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self.running,
|
||||
"pulse_interval": self.pulse_interval,
|
||||
"brain_invocations": self.brain_invocations,
|
||||
"pulse_checks": len(self.pulse_checks),
|
||||
"brain_tasks": len(self.brain_tasks),
|
||||
"latest_pulse_data": dict(self.pulse_data), # Copy
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent = Agent(
|
||||
provider="claude",
|
||||
workspace_dir="./memory_workspace",
|
||||
enable_heartbeat=False,
|
||||
)
|
||||
|
||||
pb = PulseBrain(agent, pulse_interval=10)
|
||||
pb.start()
|
||||
|
||||
try:
|
||||
print("Running... Press Ctrl+C to stop\n")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pb.stop()
|
||||
137
pyproject.toml
Normal file
137
pyproject.toml
Normal file
@@ -0,0 +1,137 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ajarbot"
|
||||
version = "0.2.0"
|
||||
description = "Multi-platform AI agent powered by Claude with memory, tools, and scheduling"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Ajarbot Team"}
|
||||
]
|
||||
keywords = ["ai", "agent", "claude", "slack", "telegram", "chatbot", "assistant"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Communications :: Chat",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
# Core dependencies (always installed)
|
||||
dependencies = [
|
||||
"watchdog>=3.0.0",
|
||||
"anthropic>=0.40.0",
|
||||
"requests>=2.31.0",
|
||||
"fastembed>=0.7.0",
|
||||
"usearch>=2.23.0",
|
||||
"numpy>=2.0.0",
|
||||
"pyyaml>=6.0.1",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Slack adapter dependencies
|
||||
slack = [
|
||||
"slack-bolt>=1.18.0",
|
||||
"slack-sdk>=3.23.0",
|
||||
]
|
||||
|
||||
# Telegram adapter dependencies
|
||||
telegram = [
|
||||
"python-telegram-bot>=20.7",
|
||||
]
|
||||
|
||||
# Google integration (Gmail + Calendar)
|
||||
google = [
|
||||
"google-auth>=2.23.0",
|
||||
"google-auth-oauthlib>=1.1.0",
|
||||
"google-auth-httplib2>=0.1.1",
|
||||
"google-api-python-client>=2.108.0",
|
||||
]
|
||||
|
||||
# Agent SDK mode (uses Claude Pro subscription)
|
||||
agent-sdk = [
|
||||
"claude-code-sdk>=0.1.0",
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn>=0.27.0",
|
||||
]
|
||||
|
||||
# All optional dependencies
|
||||
all = [
|
||||
"slack-bolt>=1.18.0",
|
||||
"slack-sdk>=3.23.0",
|
||||
"python-telegram-bot>=20.7",
|
||||
"google-auth>=2.23.0",
|
||||
"google-auth-oauthlib>=1.1.0",
|
||||
"google-auth-httplib2>=0.1.1",
|
||||
"google-api-python-client>=2.108.0",
|
||||
"claude-code-sdk>=0.1.0",
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn>=0.27.0",
|
||||
]
|
||||
|
||||
# Development dependencies
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"black>=23.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.5.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/ajarbot"
|
||||
Documentation = "https://github.com/yourusername/ajarbot#readme"
|
||||
Repository = "https://github.com/yourusername/ajarbot"
|
||||
Issues = "https://github.com/yourusername/ajarbot/issues"
|
||||
|
||||
[project.scripts]
|
||||
# Main entry point - runs ajarbot.py
|
||||
ajarbot = "ajarbot:main"
|
||||
|
||||
[tool.setuptools]
|
||||
# Auto-discover packages
|
||||
packages = ["config", "adapters", "adapters.slack", "adapters.telegram", "google_tools"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# Include YAML config templates
|
||||
config = ["*.yaml"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py310", "py311", "py312"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by black)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
@@ -23,3 +23,12 @@ google-auth>=2.23.0
|
||||
google-auth-oauthlib>=1.1.0
|
||||
google-auth-httplib2>=0.1.1
|
||||
google-api-python-client>=2.108.0
|
||||
|
||||
# Claude Agent SDK (uses Pro subscription instead of API tokens)
|
||||
claude-agent-sdk>=0.1.0
|
||||
anyio>=4.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Web fetching dependencies
|
||||
httpx>=0.27.0
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
79
run.bat
Normal file
79
run.bat
Normal file
@@ -0,0 +1,79 @@
|
||||
@echo off
|
||||
REM ========================================
|
||||
REM Ajarbot - Windows One-Command Launcher
|
||||
REM ========================================
|
||||
REM
|
||||
REM This script:
|
||||
REM 1. Creates/activates virtual environment
|
||||
REM 2. Installs dependencies if needed
|
||||
REM 3. Runs ajarbot.py
|
||||
REM
|
||||
REM Usage:
|
||||
REM run.bat Run the bot
|
||||
REM run.bat --init Generate config template
|
||||
REM run.bat --health Health check
|
||||
REM
|
||||
|
||||
echo ========================================
|
||||
echo Ajarbot Windows Launcher
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Check if virtual environment exists
|
||||
if not exist "venv\Scripts\activate.bat" (
|
||||
echo [Setup] Creating virtual environment...
|
||||
python -m venv venv
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to create virtual environment
|
||||
echo Please ensure Python 3.10+ is installed and in PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [Setup] Virtual environment created
|
||||
)
|
||||
|
||||
REM Activate virtual environment
|
||||
echo [Setup] Activating virtual environment...
|
||||
call venv\Scripts\activate.bat
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to activate virtual environment
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check if dependencies are installed (check for a key package)
|
||||
python -c "import anthropic" 2>nul
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [Setup] Installing dependencies...
|
||||
echo This may take a few minutes on first run...
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to install dependencies
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [Setup] Dependencies installed
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Run ajarbot with all arguments passed through
|
||||
echo [Launch] Starting ajarbot...
|
||||
echo.
|
||||
python ajarbot.py %*
|
||||
|
||||
REM Check exit code
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Ajarbot exited with an error
|
||||
echo ========================================
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Ajarbot stopped cleanly
|
||||
echo ========================================
|
||||
@@ -345,6 +345,17 @@ class TaskScheduler:
|
||||
print(f"[Scheduler] Task failed: {task.name}")
|
||||
print(f" Error: {e}")
|
||||
traceback.print_exc()
|
||||
if self.agent and hasattr(self.agent, 'healing_system'):
|
||||
self.agent.healing_system.capture_error(
|
||||
error=e,
|
||||
component="scheduled_tasks.py:_execute_task",
|
||||
intent=f"Executing scheduled task: {task.name}",
|
||||
context={
|
||||
"task_name": task.name,
|
||||
"schedule": task.schedule,
|
||||
"prompt": task.prompt[:100],
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_to_platform(
|
||||
self, task: ScheduledTask, response: str
|
||||
|
||||
1023
scripts/collect-homelab-config.sh
Normal file
1023
scripts/collect-homelab-config.sh
Normal file
File diff suppressed because it is too large
Load Diff
416
scripts/collect-remote.sh
Normal file
416
scripts/collect-remote.sh
Normal file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
################################################################################
|
||||
# Remote Homelab Collection Wrapper
|
||||
# Purpose: Executes the collection script on a remote Proxmox host via SSH
|
||||
# and retrieves the results back to your local machine (WSL/Linux)
|
||||
#
|
||||
# Usage: ./collect-remote.sh [PROXMOX_HOST] [OPTIONS]
|
||||
################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Color codes
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Script configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COLLECTION_SCRIPT="${SCRIPT_DIR}/collect-homelab-config.sh"
|
||||
REMOTE_SCRIPT_PATH="/tmp/collect-homelab-config.sh"
|
||||
LOCAL_OUTPUT_DIR="${SCRIPT_DIR}"
|
||||
|
||||
# SSH configuration
|
||||
SSH_USER="${SSH_USER:-root}"
|
||||
SSH_PORT="${SSH_PORT:-22}"
|
||||
SSH_OPTS="-o ConnectTimeout=10 -o StrictHostKeyChecking=no"
|
||||
|
||||
################################################################################
|
||||
# Functions
|
||||
################################################################################
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="$*"
|
||||
|
||||
case "${level}" in
|
||||
INFO)
|
||||
echo -e "${BLUE}[INFO]${NC} ${message}"
|
||||
;;
|
||||
SUCCESS)
|
||||
echo -e "${GREEN}[✓]${NC} ${message}"
|
||||
;;
|
||||
WARN)
|
||||
echo -e "${YELLOW}[WARN]${NC} ${message}"
|
||||
;;
|
||||
ERROR)
|
||||
echo -e "${RED}[ERROR]${NC} ${message}" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
banner() {
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||
echo -e "${BOLD}${CYAN} $1${NC}"
|
||||
echo -e "${BOLD}${CYAN}======================================================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
${BOLD}Remote Homelab Collection Wrapper${NC}
|
||||
|
||||
${BOLD}USAGE:${NC}
|
||||
$0 PROXMOX_HOST [OPTIONS]
|
||||
|
||||
${BOLD}DESCRIPTION:${NC}
|
||||
Executes the homelab collection script on a remote Proxmox host via SSH,
|
||||
then retrieves the results back to your local machine.
|
||||
|
||||
${BOLD}ARGUMENTS:${NC}
|
||||
PROXMOX_HOST IP address or hostname of your Proxmox server
|
||||
|
||||
${BOLD}OPTIONS:${NC}
|
||||
-u, --user USER SSH username (default: root)
|
||||
-p, --port PORT SSH port (default: 22)
|
||||
-l, --level LEVEL Collection level: basic, standard, full, paranoid
|
||||
(default: standard)
|
||||
-s, --sanitize OPT Sanitization: all, ips, none (default: passwords/tokens only)
|
||||
-o, --output DIR Local directory to store results (default: current directory)
|
||||
-k, --keep-remote Keep the export on the remote host (default: remove after download)
|
||||
-v, --verbose Verbose output
|
||||
-h, --help Show this help message
|
||||
|
||||
${BOLD}ENVIRONMENT VARIABLES:${NC}
|
||||
SSH_USER Default SSH username (default: root)
|
||||
SSH_PORT Default SSH port (default: 22)
|
||||
|
||||
${BOLD}EXAMPLES:${NC}
|
||||
# Basic usage
|
||||
$0 192.168.1.100
|
||||
|
||||
# Full collection with complete sanitization
|
||||
$0 192.168.1.100 --level full --sanitize all
|
||||
|
||||
# Custom SSH user and port
|
||||
$0 proxmox.local --user admin --port 2222
|
||||
|
||||
# Keep results on remote host
|
||||
$0 192.168.1.100 --keep-remote
|
||||
|
||||
# Verbose output with custom output directory
|
||||
$0 192.168.1.100 -v -o ~/backups/homelab
|
||||
|
||||
${BOLD}PREREQUISITES:${NC}
|
||||
1. SSH access to the Proxmox host
|
||||
2. collect-homelab-config.sh in the same directory as this script
|
||||
3. Sufficient disk space on both remote and local machines
|
||||
|
||||
${BOLD}WORKFLOW:${NC}
|
||||
1. Copies collection script to remote Proxmox host
|
||||
2. Executes the script remotely
|
||||
3. Downloads the compressed archive to local machine
|
||||
4. Optionally removes the remote copy
|
||||
5. Extracts the archive locally
|
||||
|
||||
${BOLD}NOTES:${NC}
|
||||
- Requires passwordless SSH or SSH key authentication (recommended)
|
||||
- The script will be run as the specified SSH user (typically root)
|
||||
- Remote execution output is displayed in real-time
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
check_prerequisites() {
|
||||
# Check if collection script exists
|
||||
if [[ ! -f "${COLLECTION_SCRIPT}" ]]; then
|
||||
log ERROR "Collection script not found: ${COLLECTION_SCRIPT}"
|
||||
log ERROR "Ensure collect-homelab-config.sh is in the same directory as this script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if ssh is available
|
||||
if ! command -v ssh &> /dev/null; then
|
||||
log ERROR "SSH client not found. Please install openssh-client"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if scp is available
|
||||
if ! command -v scp &> /dev/null; then
|
||||
log ERROR "SCP not found. Please install openssh-client"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
test_ssh_connection() {
|
||||
local host="$1"
|
||||
|
||||
log INFO "Testing SSH connection to ${SSH_USER}@${host}:${SSH_PORT}..."
|
||||
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "exit 0" 2>/dev/null; then
|
||||
log SUCCESS "SSH connection successful"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Cannot connect to ${SSH_USER}@${host}:${SSH_PORT}"
|
||||
log ERROR "Possible issues:"
|
||||
log ERROR " - Host is unreachable"
|
||||
log ERROR " - SSH service is not running"
|
||||
log ERROR " - Incorrect credentials"
|
||||
log ERROR " - Firewall blocking connection"
|
||||
log ERROR ""
|
||||
log ERROR "Try manually: ssh -p ${SSH_PORT} ${SSH_USER}@${host}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_proxmox_host() {
|
||||
local host="$1"
|
||||
|
||||
log INFO "Verifying Proxmox installation on remote host..."
|
||||
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "test -f /etc/pve/.version" 2>/dev/null; then
|
||||
local pve_version=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "cat /etc/pve/.version" 2>/dev/null)
|
||||
log SUCCESS "Confirmed Proxmox VE installation (version: ${pve_version})"
|
||||
return 0
|
||||
else
|
||||
log WARN "Remote host does not appear to be a Proxmox VE server"
|
||||
log WARN "Proceeding anyway, but collection may fail..."
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
upload_script() {
|
||||
local host="$1"
|
||||
|
||||
banner "Uploading Collection Script"
|
||||
|
||||
log INFO "Copying collection script to ${host}..."
|
||||
|
||||
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${COLLECTION_SCRIPT}" "${SSH_USER}@${host}:${REMOTE_SCRIPT_PATH}"; then
|
||||
log SUCCESS "Script uploaded successfully"
|
||||
|
||||
# Make executable
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "chmod +x ${REMOTE_SCRIPT_PATH}"
|
||||
log SUCCESS "Script permissions set"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Failed to upload script"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
execute_remote_collection() {
|
||||
local host="$1"
|
||||
shift
|
||||
local collection_args=("$@")
|
||||
|
||||
banner "Executing Collection on Remote Host"
|
||||
|
||||
log INFO "Running collection script on ${host}..."
|
||||
log INFO "Arguments: ${collection_args[*]}"
|
||||
|
||||
# Build the remote command
|
||||
local remote_cmd="${REMOTE_SCRIPT_PATH} ${collection_args[*]}"
|
||||
|
||||
# Execute remotely and stream output
|
||||
if ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "${remote_cmd}"; then
|
||||
log SUCCESS "Collection completed successfully on remote host"
|
||||
return 0
|
||||
else
|
||||
log ERROR "Collection failed on remote host"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_results() {
|
||||
local host="$1"
|
||||
local output_dir="$2"
|
||||
|
||||
banner "Downloading Results"
|
||||
|
||||
log INFO "Finding remote export archive..."
|
||||
|
||||
# Find the most recent export archive
|
||||
local remote_archive=$(ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||
"ls -t /root/homelab-export-*.tar.gz 2>/dev/null | head -1" 2>/dev/null)
|
||||
|
||||
if [[ -z "${remote_archive}" ]]; then
|
||||
log ERROR "No export archive found on remote host"
|
||||
log ERROR "Collection may have failed or compression was disabled"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log INFO "Found archive: ${remote_archive}"
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
# Download the archive
|
||||
local local_archive="${output_dir}/$(basename "${remote_archive}")"
|
||||
|
||||
log INFO "Downloading to: ${local_archive}"
|
||||
|
||||
if scp ${SSH_OPTS} -P "${SSH_PORT}" "${SSH_USER}@${host}:${remote_archive}" "${local_archive}"; then
|
||||
log SUCCESS "Archive downloaded successfully"
|
||||
|
||||
# Extract the archive
|
||||
log INFO "Extracting archive..."
|
||||
if tar -xzf "${local_archive}" -C "${output_dir}"; then
|
||||
log SUCCESS "Archive extracted to: ${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||
|
||||
# Show summary
|
||||
local extracted_dir="${output_dir}/$(basename "${local_archive}" .tar.gz)"
|
||||
if [[ -f "${extracted_dir}/SUMMARY.md" ]]; then
|
||||
echo ""
|
||||
log INFO "Collection Summary:"
|
||||
echo ""
|
||||
head -30 "${extracted_dir}/SUMMARY.md"
|
||||
echo ""
|
||||
log INFO "Full summary: ${extracted_dir}/SUMMARY.md"
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
log ERROR "Failed to extract archive"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log ERROR "Failed to download archive"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_remote() {
|
||||
local host="$1"
|
||||
local keep_remote="$2"
|
||||
|
||||
if [[ "${keep_remote}" == "true" ]]; then
|
||||
log INFO "Keeping export on remote host (--keep-remote specified)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
banner "Cleaning Up Remote Host"
|
||||
|
||||
log INFO "Removing export files from remote host..."
|
||||
|
||||
# Remove the script
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" "rm -f ${REMOTE_SCRIPT_PATH}" 2>/dev/null || true
|
||||
|
||||
# Remove export directories and archives
|
||||
ssh ${SSH_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${host}" \
|
||||
"rm -rf /root/homelab-export-* 2>/dev/null" 2>/dev/null || true
|
||||
|
||||
log SUCCESS "Remote cleanup completed"
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Main Execution
|
||||
################################################################################
|
||||
|
||||
main() {
|
||||
# Parse arguments
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local proxmox_host=""
|
||||
local collection_level="standard"
|
||||
local sanitize_option=""
|
||||
local keep_remote="false"
|
||||
local verbose="false"
|
||||
|
||||
# First argument is the host
|
||||
proxmox_host="$1"
|
||||
shift
|
||||
|
||||
# Parse remaining options
|
||||
local collection_args=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-u|--user)
|
||||
SSH_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--port)
|
||||
SSH_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--level)
|
||||
collection_level="$2"
|
||||
collection_args+=("--level" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-s|--sanitize)
|
||||
sanitize_option="$2"
|
||||
collection_args+=("--sanitize" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
LOCAL_OUTPUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-k|--keep-remote)
|
||||
keep_remote="true"
|
||||
shift
|
||||
;;
|
||||
-v|--verbose)
|
||||
verbose="true"
|
||||
collection_args+=("--verbose")
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log ERROR "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate host
|
||||
if [[ -z "${proxmox_host}" ]]; then
|
||||
log ERROR "Proxmox host not specified"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Display configuration
|
||||
banner "Remote Homelab Collection"
|
||||
echo -e "${BOLD}Target Host:${NC} ${proxmox_host}"
|
||||
echo -e "${BOLD}SSH User:${NC} ${SSH_USER}"
|
||||
echo -e "${BOLD}SSH Port:${NC} ${SSH_PORT}"
|
||||
echo -e "${BOLD}Collection Level:${NC} ${collection_level}"
|
||||
echo -e "${BOLD}Output Directory:${NC} ${LOCAL_OUTPUT_DIR}"
|
||||
echo -e "${BOLD}Keep Remote:${NC} ${keep_remote}"
|
||||
echo ""
|
||||
|
||||
# Execute workflow
|
||||
check_prerequisites
|
||||
test_ssh_connection "${proxmox_host}" || exit 1
|
||||
verify_proxmox_host "${proxmox_host}"
|
||||
upload_script "${proxmox_host}" || exit 1
|
||||
execute_remote_collection "${proxmox_host}" "${collection_args[@]}" || exit 1
|
||||
download_results "${proxmox_host}" "${LOCAL_OUTPUT_DIR}" || exit 1
|
||||
cleanup_remote "${proxmox_host}" "${keep_remote}"
|
||||
|
||||
banner "Collection Complete"
|
||||
|
||||
log SUCCESS "Homelab infrastructure export completed successfully"
|
||||
log INFO "Results are available in: ${LOCAL_OUTPUT_DIR}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
152
scripts/collection_output.txt
Normal file
152
scripts/collection_output.txt
Normal file
@@ -0,0 +1,152 @@
|
||||
=== COLLECTION OUTPUT ===
|
||||
|
||||
================================================================================
|
||||
Starting Homelab Infrastructure Collection
|
||||
================================================================================
|
||||
|
||||
[INFO] Collection Level: full
|
||||
[INFO] Output Directory: /tmp/homelab-export
|
||||
[INFO] Sanitization: IPs=false | Passwords=false | Tokens=false
|
||||
|
||||
================================================================================
|
||||
Creating Directory Structure
|
||||
================================================================================
|
||||
|
||||
[✓] Directory structure created at: /tmp/homelab-export
|
||||
|
||||
================================================================================
|
||||
Collecting System Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Proxmox VE version
|
||||
[✓] Collected Hostname
|
||||
[✓] Collected Kernel information
|
||||
[✓] Collected System uptime
|
||||
[✓] Collected System date/time
|
||||
[✓] Collected CPU information
|
||||
[✓] Collected Detailed CPU info
|
||||
[✓] Collected Memory information
|
||||
[✓] Collected Detailed memory info
|
||||
[✓] Collected Filesystem usage
|
||||
[✓] Collected Block devices
|
||||
[✓] Collected LVM physical volumes
|
||||
[✓] Collected LVM volume groups
|
||||
[✓] Collected LVM logical volumes
|
||||
[✓] Collected IP addresses
|
||||
[✓] Collected Routing table
|
||||
[✓] Collected Listening sockets
|
||||
[✓] Collected Installed packages
|
||||
|
||||
================================================================================
|
||||
Collecting Proxmox Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Datacenter config
|
||||
[✓] Collected Storage config
|
||||
[✓] Collected User config
|
||||
[✓] Collected Auth public key
|
||||
[WARN] Failed to copy directory HA configuration from /etc/pve/ha
|
||||
|
||||
================================================================================
|
||||
Collecting VM Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected VM 100 (docker-hub) config
|
||||
[✓] Collected VM 101 (monitoring-docker) config
|
||||
[✓] Collected VM 104 (ubuntu-dev) config
|
||||
[✓] Collected VM 105 (pfSense-Firewall) config
|
||||
[✓] Collected VM 106 (Ansible-Control) config
|
||||
[✓] Collected VM 107 (ubuntu-docker) config
|
||||
[✓] Collected VM 108 (CML) config
|
||||
[✓] Collected VM 114 (haos) config
|
||||
[✓] Collected VM 119 (moltbot) config
|
||||
|
||||
================================================================================
|
||||
Collecting LXC Container Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Container 102 (nginx) config
|
||||
[✓] Collected Container 103 (netbox) config
|
||||
[✓] Collected Container 112 (twingate-connector) config
|
||||
[✓] Collected Container 113 (n8n
|
||||
n8n
|
||||
n8n) config
|
||||
[✓] Collected Container 117 (test-cve-database) config
|
||||
|
||||
================================================================================
|
||||
Collecting Network Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Network interfaces config
|
||||
[WARN] Failed to copy directory Additional interface configs from /etc/network/interfaces.d
|
||||
[✓] Collected SDN configuration
|
||||
[✓] Collected Hosts file
|
||||
[✓] Collected DNS resolver config
|
||||
|
||||
================================================================================
|
||||
Collecting Storage Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Storage status
|
||||
[✓] Collected ZFS pool status
|
||||
[✓] Collected ZFS pool list
|
||||
[✓] Collected ZFS datasets
|
||||
[✓] Collected Samba config
|
||||
[✓] Collected iSCSI initiator config
|
||||
|
||||
================================================================================
|
||||
Collecting Backup Configurations
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Vzdump config
|
||||
|
||||
================================================================================
|
||||
Collecting Cluster Information
|
||||
================================================================================
|
||||
|
||||
[WARN] Failed to execute: pvecm status (Cluster status)
|
||||
[WARN] Failed to execute: pvecm nodes (Cluster nodes)
|
||||
[✓] Collected Cluster resources
|
||||
[✓] Collected Recent tasks
|
||||
|
||||
================================================================================
|
||||
Collecting Guest Information
|
||||
================================================================================
|
||||
|
||||
[✓] Collected VM list
|
||||
[✓] Collected Container list
|
||||
[✓] Collected All guests (JSON)
|
||||
|
||||
================================================================================
|
||||
Collecting Service Configurations (Advanced)
|
||||
================================================================================
|
||||
|
||||
[✓] Collected Systemd services
|
||||
|
||||
================================================================================
|
||||
Generating Documentation
|
||||
================================================================================
|
||||
|
||||
[✓] Generated README.md
|
||||
|
||||
================================================================================
|
||||
Generating Summary Report
|
||||
================================================================================
|
||||
|
||||
[✓] Generated SUMMARY.md
|
||||
|
||||
================================================================================
|
||||
Collection Complete
|
||||
================================================================================
|
||||
|
||||
[✓] Total items collected: 53
|
||||
[INFO] Total items skipped: 1
|
||||
[WARN] Total errors: 4
|
||||
[WARN] Review /tmp/homelab-export/collection.log for details
|
||||
|
||||
Export Location: /tmp/homelab-export
|
||||
Summary Report: /tmp/homelab-export/SUMMARY.md
|
||||
Collection Log: /tmp/homelab-export/collection.log
|
||||
|
||||
|
||||
Exit code: 0
|
||||
53
scripts/proxmox_ssh.py
Normal file
53
scripts/proxmox_ssh.py
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||
Uses paramiko for native Python SSH (no sshpass needed).
|
||||
Usage: python proxmox_ssh.py "command to run"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import paramiko
|
||||
|
||||
PROXMOX_HOST = "192.168.2.100"
|
||||
PROXMOX_USER = "root"
|
||||
PROXMOX_PASS = "Nbkx4mdmay1)"
|
||||
PROXMOX_PORT = 22
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
def run_command(command: str) -> tuple:
|
||||
"""Execute a command on the Proxmox server via SSH.
|
||||
Returns (stdout, stderr, exit_code).
|
||||
"""
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
client.connect(
|
||||
hostname=PROXMOX_HOST,
|
||||
port=PROXMOX_PORT,
|
||||
username=PROXMOX_USER,
|
||||
password=PROXMOX_PASS,
|
||||
timeout=TIMEOUT,
|
||||
look_for_keys=False,
|
||||
allow_agent=False,
|
||||
)
|
||||
stdin, stdout, stderr = client.exec_command(command, timeout=TIMEOUT)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
return out, err, exit_code
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python proxmox_ssh.py \"command\"")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
out, err, code = run_command(cmd)
|
||||
if out:
|
||||
print(out, end="")
|
||||
if err:
|
||||
print(err, end="", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
9
scripts/proxmox_ssh.sh
Normal file
9
scripts/proxmox_ssh.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Proxmox SSH Helper - serviceslab (192.168.2.100)
|
||||
# Usage: proxmox_ssh.sh "command to run"
|
||||
|
||||
PROXMOX_HOST="192.168.2.100"
|
||||
PROXMOX_USER="root"
|
||||
PROXMOX_PASS="Nbkx4mdmay1)"
|
||||
|
||||
sshpass -p "$PROXMOX_PASS" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${PROXMOX_USER}@${PROXMOX_HOST}" "$1"
|
||||
135
self_healing.py
Normal file
135
self_healing.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Self-Healing System - Phase 1: Error Capture and Logging.
|
||||
|
||||
Captures all errors with full context and logs them to MEMORY.md.
|
||||
No auto-fixing in this phase - observation only.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorContext:
|
||||
"""Full context for a captured error."""
|
||||
|
||||
error_type: str # Exception class name
|
||||
message: str # Error message
|
||||
stack_trace: str # Full traceback
|
||||
component: str # Where it happened (e.g., "tools.py:read_file")
|
||||
intent: str # What was being attempted
|
||||
context: Dict[str, Any] # Additional context (tool inputs, user message, etc.)
|
||||
timestamp: str # ISO 8601 format
|
||||
|
||||
|
||||
class SelfHealingSystem:
|
||||
"""
|
||||
Phase 1: Error observation infrastructure.
|
||||
|
||||
Captures errors with full context, deduplicates via error signatures,
|
||||
and logs them to MEMORY.md for future analysis.
|
||||
"""
|
||||
|
||||
def __init__(self, memory_system: Any, agent: Any) -> None:
|
||||
self.memory = memory_system
|
||||
self.agent = agent
|
||||
self._error_counts: Dict[str, int] = {}
|
||||
|
||||
def capture_error(
|
||||
self,
|
||||
error: Exception,
|
||||
component: str,
|
||||
intent: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Capture an error with full context and log it.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred.
|
||||
component: Where the error happened (e.g., "tools.py:read_file").
|
||||
intent: What was being attempted when the error occurred.
|
||||
context: Additional context such as tool inputs, user message, etc.
|
||||
"""
|
||||
error_ctx = ErrorContext(
|
||||
error_type=type(error).__name__,
|
||||
message=str(error),
|
||||
stack_trace=traceback.format_exc(),
|
||||
component=component,
|
||||
intent=intent,
|
||||
context=context or {},
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
signature = self._generate_signature(error_ctx)
|
||||
|
||||
# Track attempt count
|
||||
self._error_counts[signature] = self._error_counts.get(signature, 0) + 1
|
||||
attempt = self._error_counts[signature]
|
||||
|
||||
if attempt <= 3:
|
||||
self._log_error(error_ctx, attempt)
|
||||
|
||||
print(
|
||||
f"[SelfHealing] Error captured: {error_ctx.error_type} "
|
||||
f"in {error_ctx.component} (attempt {attempt}/3)"
|
||||
)
|
||||
|
||||
def _generate_signature(self, error_ctx: ErrorContext) -> str:
|
||||
"""Generate a deduplication signature for an error.
|
||||
|
||||
Uses first 8 characters of SHA-256 hash of error type,
|
||||
component, and message combined.
|
||||
"""
|
||||
raw = f"{error_ctx.error_type}:{error_ctx.component}:{error_ctx.message}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:8]
|
||||
|
||||
def _log_error(self, error_ctx: ErrorContext, attempt: int) -> None:
|
||||
"""Log an error to MEMORY.md via the memory system.
|
||||
|
||||
Formats the error as a markdown entry and appends it to
|
||||
the persistent MEMORY.md file (daily=False).
|
||||
"""
|
||||
# Serialize context to JSON for readability
|
||||
try:
|
||||
context_json = json.dumps(error_ctx.context, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
context_json = str(error_ctx.context)
|
||||
|
||||
# Format timestamp for the header
|
||||
try:
|
||||
dt = datetime.fromisoformat(error_ctx.timestamp)
|
||||
header_time = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
header_time = error_ctx.timestamp
|
||||
|
||||
log_entry = (
|
||||
f"## Error Log - {header_time}\n"
|
||||
f"\n"
|
||||
f"**Type**: {error_ctx.error_type}\n"
|
||||
f"**Component**: {error_ctx.component}\n"
|
||||
f"**Intent**: {error_ctx.intent}\n"
|
||||
f"**Attempt**: {attempt}/3\n"
|
||||
f"**Message**: {error_ctx.message}\n"
|
||||
f"\n"
|
||||
f"**Context**:\n"
|
||||
f"```json\n"
|
||||
f"{context_json}\n"
|
||||
f"```\n"
|
||||
f"\n"
|
||||
f"**Stack Trace**:\n"
|
||||
f"```\n"
|
||||
f"{error_ctx.stack_trace}\n"
|
||||
f"```\n"
|
||||
f"---"
|
||||
)
|
||||
|
||||
try:
|
||||
self.memory.write_memory(log_entry, daily=False)
|
||||
except Exception as e:
|
||||
# Last resort: print to console if memory write fails
|
||||
print(f"[SelfHealing] Failed to write error log to MEMORY.md: {e}")
|
||||
print(f"[SelfHealing] Error was: {error_ctx.error_type}: {error_ctx.message}")
|
||||
342
tools.py
342
tools.py
@@ -100,6 +100,21 @@ TOOL_DEFINITIONS = [
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a location using OpenWeatherMap API. Returns temperature, conditions, and brief summary.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City name or 'City, Country' (e.g., 'Phoenix, US' or 'London, GB'). Defaults to Phoenix, AZ if not specified.",
|
||||
"default": "Phoenix, US",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
# Gmail tools
|
||||
{
|
||||
"name": "send_email",
|
||||
@@ -324,30 +339,61 @@ TOOL_DEFINITIONS = [
|
||||
]
|
||||
|
||||
|
||||
def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str:
|
||||
"""Execute a tool and return the result as a string."""
|
||||
def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any = None) -> str:
|
||||
"""Execute a tool and return the result as a string.
|
||||
|
||||
This is used by the Direct API tool loop in agent.py.
|
||||
In Agent SDK mode, tools are executed automatically via MCP servers
|
||||
and this function is not called.
|
||||
"""
|
||||
import time
|
||||
from logging_config import get_tool_logger
|
||||
|
||||
logger = get_tool_logger()
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# File tools
|
||||
result_str = None
|
||||
|
||||
# --- File and system tools (sync handlers) ---
|
||||
if tool_name == "read_file":
|
||||
return _read_file(tool_input["file_path"])
|
||||
result_str = _read_file(tool_input["file_path"])
|
||||
elif tool_name == "write_file":
|
||||
return _write_file(tool_input["file_path"], tool_input["content"])
|
||||
result_str = _write_file(tool_input["file_path"], tool_input["content"])
|
||||
elif tool_name == "edit_file":
|
||||
return _edit_file(
|
||||
result_str = _edit_file(
|
||||
tool_input["file_path"],
|
||||
tool_input["old_text"],
|
||||
tool_input["new_text"],
|
||||
)
|
||||
elif tool_name == "list_directory":
|
||||
path = tool_input.get("path", ".")
|
||||
return _list_directory(path)
|
||||
result_str = _list_directory(tool_input.get("path", "."))
|
||||
elif tool_name == "run_command":
|
||||
command = tool_input["command"]
|
||||
working_dir = tool_input.get("working_dir", ".")
|
||||
return _run_command(command, working_dir)
|
||||
# Gmail tools
|
||||
result_str = _run_command(
|
||||
tool_input["command"],
|
||||
tool_input.get("working_dir", "."),
|
||||
)
|
||||
|
||||
# --- Weather tool (sync handler) ---
|
||||
elif tool_name == "get_weather":
|
||||
result_str = _get_weather(tool_input.get("location", "Phoenix, US"))
|
||||
|
||||
# --- Async MCP tools (web, zettelkasten, gitea) ---
|
||||
elif tool_name in {
|
||||
"web_fetch", "fleeting_note", "daily_note", "literature_note",
|
||||
"permanent_note", "search_vault", "search_by_tags",
|
||||
"gitea_read_file", "gitea_list_files", "gitea_search_code", "gitea_get_tree",
|
||||
}:
|
||||
# Note: These tools should only execute via Agent SDK MCP servers.
|
||||
# If you're seeing this message, the tool routing needs adjustment.
|
||||
return (
|
||||
f"[MCP Tool] '{tool_name}' should be dispatched by Agent SDK MCP server. "
|
||||
f"Direct API fallback is disabled for this tool to ensure zero API cost."
|
||||
)
|
||||
|
||||
# --- Google tools (sync handlers using traditional API clients) ---
|
||||
elif tool_name == "send_email":
|
||||
return _send_email(
|
||||
result_str = _send_email(
|
||||
to=tool_input["to"],
|
||||
subject=tool_input["subject"],
|
||||
body=tool_input["body"],
|
||||
@@ -355,25 +401,24 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str:
|
||||
reply_to_message_id=tool_input.get("reply_to_message_id"),
|
||||
)
|
||||
elif tool_name == "read_emails":
|
||||
return _read_emails(
|
||||
result_str = _read_emails(
|
||||
query=tool_input.get("query", ""),
|
||||
max_results=tool_input.get("max_results", 10),
|
||||
include_body=tool_input.get("include_body", False),
|
||||
)
|
||||
elif tool_name == "get_email":
|
||||
return _get_email(
|
||||
result_str = _get_email(
|
||||
message_id=tool_input["message_id"],
|
||||
format_type=tool_input.get("format", "text"),
|
||||
)
|
||||
# Calendar tools
|
||||
elif tool_name == "read_calendar":
|
||||
return _read_calendar(
|
||||
result_str = _read_calendar(
|
||||
days_ahead=tool_input.get("days_ahead", 7),
|
||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||
max_results=tool_input.get("max_results", 20),
|
||||
)
|
||||
elif tool_name == "create_calendar_event":
|
||||
return _create_calendar_event(
|
||||
result_str = _create_calendar_event(
|
||||
summary=tool_input["summary"],
|
||||
start_time=tool_input["start_time"],
|
||||
end_time=tool_input["end_time"],
|
||||
@@ -382,13 +427,12 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str:
|
||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||
)
|
||||
elif tool_name == "search_calendar":
|
||||
return _search_calendar(
|
||||
result_str = _search_calendar(
|
||||
query=tool_input["query"],
|
||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||
)
|
||||
# Contacts tools
|
||||
elif tool_name == "create_contact":
|
||||
return _create_contact(
|
||||
result_str = _create_contact(
|
||||
given_name=tool_input["given_name"],
|
||||
family_name=tool_input.get("family_name", ""),
|
||||
email=tool_input.get("email", ""),
|
||||
@@ -396,18 +440,124 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str:
|
||||
notes=tool_input.get("notes"),
|
||||
)
|
||||
elif tool_name == "list_contacts":
|
||||
return _list_contacts(
|
||||
result_str = _list_contacts(
|
||||
max_results=tool_input.get("max_results", 100),
|
||||
query=tool_input.get("query"),
|
||||
)
|
||||
elif tool_name == "get_contact":
|
||||
return _get_contact(
|
||||
result_str = _get_contact(
|
||||
resource_name=tool_input["resource_name"],
|
||||
)
|
||||
|
||||
# --- Obsidian MCP tools (external server with fallback) ---
|
||||
elif tool_name in {
|
||||
"obsidian_read_note", "obsidian_update_note",
|
||||
"obsidian_search_replace", "obsidian_global_search",
|
||||
"obsidian_list_notes", "obsidian_manage_frontmatter",
|
||||
"obsidian_manage_tags", "obsidian_delete_note",
|
||||
}:
|
||||
result_str = _execute_obsidian_tool(tool_name, tool_input, logger, start_time)
|
||||
|
||||
# --- Unknown tool ---
|
||||
if result_str is not None:
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
logger.log_tool_call(
|
||||
tool_name=tool_name,
|
||||
inputs=tool_input,
|
||||
success=True,
|
||||
result=result_str,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return result_str
|
||||
else:
|
||||
return f"Error: Unknown tool '{tool_name}'"
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
error_msg = f"Error: Unknown tool '{tool_name}'"
|
||||
logger.log_tool_call(
|
||||
tool_name=tool_name,
|
||||
inputs=tool_input,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return error_msg
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing {tool_name}: {str(e)}"
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
error_msg = str(e)
|
||||
|
||||
# Log the error
|
||||
logger.log_tool_call(
|
||||
tool_name=tool_name,
|
||||
inputs=tool_input,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
# Capture in healing system if available
|
||||
if healing_system:
|
||||
healing_system.capture_error(
|
||||
error=e,
|
||||
component=f"tools.py:{tool_name}",
|
||||
intent=f"Executing {tool_name} tool",
|
||||
context={"tool_name": tool_name, "input": tool_input},
|
||||
)
|
||||
return f"Error executing {tool_name}: {error_msg}"
|
||||
|
||||
|
||||
def _extract_mcp_result(result: Any) -> str:
|
||||
"""Convert an MCP tool result dict to a plain string."""
|
||||
if isinstance(result, dict):
|
||||
if "error" in result:
|
||||
return f"Error: {result['error']}"
|
||||
elif "content" in result:
|
||||
content = result["content"]
|
||||
if isinstance(content, list):
|
||||
# Extract text from content blocks
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
return "\n".join(parts) if parts else str(content)
|
||||
return str(content)
|
||||
return str(result)
|
||||
return str(result)
|
||||
|
||||
|
||||
def _execute_obsidian_tool(
|
||||
tool_name: str,
|
||||
tool_input: Dict[str, Any],
|
||||
logger: Any,
|
||||
start_time: float,
|
||||
) -> str:
|
||||
"""Execute an Obsidian MCP tool with fallback to custom tools."""
|
||||
try:
|
||||
from obsidian_mcp import (
|
||||
check_obsidian_health,
|
||||
should_fallback_to_custom,
|
||||
)
|
||||
|
||||
if check_obsidian_health():
|
||||
return (
|
||||
f"[Obsidian MCP] Tool '{tool_name}' should be dispatched "
|
||||
f"by the Agent SDK MCP server. If you're seeing this, "
|
||||
f"the tool call routing may need adjustment."
|
||||
)
|
||||
elif should_fallback_to_custom():
|
||||
fallback_result = _obsidian_fallback(tool_name, tool_input)
|
||||
if fallback_result is not None:
|
||||
return fallback_result
|
||||
return (
|
||||
f"Error: Obsidian is not running and no fallback "
|
||||
f"available for '{tool_name}'."
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Error: Obsidian is not running and fallback is disabled. "
|
||||
f"Please start Obsidian desktop app."
|
||||
)
|
||||
except ImportError:
|
||||
return f"Error: obsidian_mcp module not found for tool '{tool_name}'"
|
||||
|
||||
|
||||
# Maximum characters of tool output to return (prevents token explosion)
|
||||
@@ -523,6 +673,65 @@ def _run_command(command: str, working_dir: str) -> str:
|
||||
return f"Error running command: {str(e)}"
|
||||
|
||||
|
||||
def _get_weather(location: str = "Phoenix, US") -> str:
|
||||
"""Get current weather for a location using OpenWeatherMap API.
|
||||
|
||||
Args:
|
||||
location: City name or 'City, Country' (e.g., 'Phoenix, US')
|
||||
|
||||
Returns:
|
||||
Weather summary string
|
||||
"""
|
||||
import requests
|
||||
|
||||
api_key = os.getenv("OPENWEATHERMAP_API_KEY")
|
||||
if not api_key:
|
||||
return "Error: OPENWEATHERMAP_API_KEY not found in environment variables. Please add it to your .env file."
|
||||
|
||||
try:
|
||||
# OpenWeatherMap API endpoint
|
||||
base_url = "http://api.openweathermap.org/data/2.5/weather"
|
||||
params = {
|
||||
"q": location,
|
||||
"appid": api_key,
|
||||
"units": "imperial" # Fahrenheit
|
||||
}
|
||||
|
||||
response = requests.get(base_url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Extract weather data
|
||||
temp = data["main"]["temp"]
|
||||
feels_like = data["main"]["feels_like"]
|
||||
description = data["weather"][0]["description"].capitalize()
|
||||
humidity = data["main"]["humidity"]
|
||||
wind_speed = data["wind"]["speed"]
|
||||
city = data["name"]
|
||||
|
||||
# Format weather summary
|
||||
summary = f"**{city} Weather:**\n"
|
||||
summary += f"🌡️ {temp}°F (feels like {feels_like}°F)\n"
|
||||
summary += f"☁️ {description}\n"
|
||||
summary += f"💧 Humidity: {humidity}%\n"
|
||||
summary += f"💨 Wind: {wind_speed} mph"
|
||||
|
||||
return summary
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
return "Error: Invalid OpenWeatherMap API key. Please check your OPENWEATHERMAP_API_KEY in .env file."
|
||||
elif e.response.status_code == 404:
|
||||
return f"Error: Location '{location}' not found. Try format: 'City, Country' (e.g., 'Phoenix, US')"
|
||||
else:
|
||||
return f"Error: OpenWeatherMap API error: {e}"
|
||||
except requests.exceptions.Timeout:
|
||||
return "Error: Weather API request timed out. Please try again."
|
||||
except Exception as e:
|
||||
return f"Error getting weather: {str(e)}"
|
||||
|
||||
|
||||
# Google Tools Handlers
|
||||
|
||||
|
||||
@@ -813,3 +1022,86 @@ def _get_contact(resource_name: str) -> str:
|
||||
return "\n".join(output)
|
||||
else:
|
||||
return f"Error getting contact: {result.get('error', 'Unknown error')}"
|
||||
|
||||
|
||||
def _obsidian_fallback(tool_name: str, tool_input: Dict[str, Any]) -> Optional[str]:
|
||||
"""Map Obsidian MCP tools to custom zettelkasten/file tool equivalents.
|
||||
|
||||
Returns None if no fallback is possible for the given tool.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
if tool_name == "obsidian_read_note":
|
||||
# Map to read_file with vault-relative path
|
||||
vault_path = Path("memory_workspace/obsidian")
|
||||
file_path = str(vault_path / tool_input.get("filePath", ""))
|
||||
return _read_file(file_path)
|
||||
|
||||
elif tool_name == "obsidian_global_search":
|
||||
# Map to search_vault
|
||||
import anyio
|
||||
from mcp_tools import search_vault_tool
|
||||
result = anyio.run(search_vault_tool, {
|
||||
"query": tool_input.get("query", ""),
|
||||
"limit": tool_input.get("pageSize", 10),
|
||||
})
|
||||
if isinstance(result, dict) and "content" in result:
|
||||
return str(result["content"])
|
||||
return str(result)
|
||||
|
||||
elif tool_name == "obsidian_list_notes":
|
||||
# Map to list_directory
|
||||
vault_path = Path("memory_workspace/obsidian")
|
||||
dir_path = str(vault_path / tool_input.get("dirPath", ""))
|
||||
return _list_directory(dir_path)
|
||||
|
||||
elif tool_name == "obsidian_update_note":
|
||||
# Map to write_file or edit_file based on mode
|
||||
vault_path = Path("memory_workspace/obsidian")
|
||||
target = tool_input.get("targetIdentifier", "")
|
||||
content = tool_input.get("content", "")
|
||||
mode = tool_input.get("wholeFileMode", "overwrite")
|
||||
file_path = str(vault_path / target)
|
||||
|
||||
if mode == "overwrite":
|
||||
return _write_file(file_path, content)
|
||||
elif mode == "append":
|
||||
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
|
||||
return _write_file(file_path, existing + "\n" + content)
|
||||
elif mode == "prepend":
|
||||
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
|
||||
return _write_file(file_path, content + "\n" + existing)
|
||||
|
||||
elif tool_name == "obsidian_search_replace":
|
||||
# Map to edit_file
|
||||
vault_path = Path("memory_workspace/obsidian")
|
||||
target = tool_input.get("targetIdentifier", "")
|
||||
file_path = str(vault_path / target)
|
||||
replacements = tool_input.get("replacements", [])
|
||||
if replacements:
|
||||
first = replacements[0]
|
||||
return _edit_file(
|
||||
file_path,
|
||||
first.get("search", ""),
|
||||
first.get("replace", ""),
|
||||
)
|
||||
|
||||
elif tool_name == "obsidian_manage_tags":
|
||||
# Map to search_by_tags (list operation only)
|
||||
operation = tool_input.get("operation", "list")
|
||||
if operation == "list":
|
||||
tags = tool_input.get("tags", "")
|
||||
if isinstance(tags, list):
|
||||
tags = ",".join(tags)
|
||||
import anyio
|
||||
from mcp_tools import search_by_tags_tool
|
||||
result = anyio.run(search_by_tags_tool, {"tags": tags})
|
||||
if isinstance(result, dict) and "content" in result:
|
||||
return str(result["content"])
|
||||
return str(result)
|
||||
|
||||
# No fallback possible for:
|
||||
# - obsidian_manage_frontmatter (new capability, no custom equivalent)
|
||||
# - obsidian_delete_note (safety: deliberate no-fallback for destructive ops)
|
||||
# - obsidian_manage_tags add/remove (requires YAML frontmatter parsing)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user