Compare commits

..

7 Commits

Author SHA1 Message Date
fe7c146dc6 feat: Add Gitea MCP integration and project cleanup
## New Features
- **Gitea MCP Tools** (zero API cost):
  - gitea_read_file: Read files from homelab repo
  - gitea_list_files: Browse directories
  - gitea_search_code: Search by filename
  - gitea_get_tree: Get directory tree
- **Gitea Client** (gitea_tools/client.py): REST API wrapper with OAuth
- **Proxmox SSH Scripts** (scripts/): Homelab data collection utilities
- **Obsidian MCP Support** (obsidian_mcp.py): Advanced vault operations
- **Voice Integration Plan** (JARVIS_VOICE_INTEGRATION_PLAN.md)

## Improvements
- **Increased timeout**: 5min → 10min for complex tasks (llm_interface.py)
- **Removed Direct API fallback**: Gitea tools are MCP-only (zero cost)
- **Updated .env.example**: Added Obsidian MCP configuration
- **Enhanced .gitignore**: Protect personal memory files (SOUL.md, MEMORY.md)

## Cleanup
- Deleted 24 obsolete files (temp/test/experimental scripts, outdated docs)
- Untracked personal memory files (SOUL.md, MEMORY.md now in .gitignore)
- Removed: AGENT_SDK_IMPLEMENTATION.md, HYBRID_SEARCH_SUMMARY.md,
  IMPLEMENTATION_SUMMARY.md, MIGRATION.md, test_agent_sdk.py, etc.

## Configuration
- Added config/gitea_config.example.yaml (Gitea setup template)
- Added config/obsidian_mcp.example.yaml (Obsidian MCP template)
- Updated scheduled_tasks.yaml with new task examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 20:31:32 -07:00
0271dea551 Add comprehensive structured logging system
Features:
- JSON-formatted logs for easy parsing and analysis
- Rotating log files (prevents disk space issues)
  * ajarbot.log: All events, 10MB rotation, 5 backups
  * errors.log: Errors only, 5MB rotation, 3 backups
  * tools.log: Tool execution tracking, 10MB rotation, 3 backups

Tool Execution Tracking:
- Every tool call logged with inputs, outputs, duration
- Success/failure status tracking
- Performance metrics (execution time in milliseconds)
- Error messages captured with full context

Logging Integration:
- tools.py: All tool executions automatically logged
- Structured logger classes with context preservation
- Console output (human-readable) + file logs (JSON)
- Separate error log for quick issue identification

Log Analysis:
- JSON format enables programmatic analysis
- Easy to search for patterns (max tokens, iterations, etc.)
- Performance tracking (slow tools, failure rates)
- Historical debugging with full context

Documentation:
- LOGGING.md: Complete usage guide
- Log analysis examples with jq commands
- Error pattern reference
- Maintenance and integration instructions

Benefits:
- Quick error diagnosis with separate errors.log
- Performance monitoring and optimization
- Historical analysis for troubleshooting
- Automatic log rotation (max 95MB total)

Updated .gitignore to exclude logs/ directory

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 16:32:18 -07:00
50cf7165cb Add sub-agent orchestration, MCP tools, and critical bug fixes
Major Features:
- Sub-agent orchestration system with dynamic specialist spawning
  * spawn_sub_agent(): Create specialists with custom prompts
  * delegate(): Convenience method for task delegation
  * Cached specialists for reuse
  * Separate conversation histories and focused context

- MCP (Model Context Protocol) tool integration
  * Zettelkasten: fleeting_note, daily_note, permanent_note, literature_note
  * Search: search_vault (hybrid search), search_by_tags
  * Web: web_fetch for real-time data
  * Zero-cost file/system operations on Pro subscription

Critical Bug Fixes:
- Fixed max tool iterations (15 → 30, configurable)
- Fixed max_tokens error in Agent SDK query() call
- Fixed MCP tool routing in execute_tool()
  * Routes zettelkasten + web tools to async handlers
  * Prevents "Unknown tool" errors

Documentation:
- SUB_AGENTS.md: Complete guide to sub-agent system
- MCP_MIGRATION.md: Agent SDK migration details
- SOUL.example.md: Sanitized bot identity template
- scheduled_tasks.example.yaml: Sanitized task config template

Security:
- Added obsidian vault to .gitignore
- Protected SOUL.md and MEMORY.md (personal configs)
- Sanitized example configs with placeholders

Dependencies:
- Added beautifulsoup4, httpx, lxml for web scraping
- Updated requirements.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 07:43:31 -07:00
911d362ba2 Optimize for Claude Agent SDK: Memory, context, and model selection
## Memory & Context Optimizations

### agent.py
- MAX_CONTEXT_MESSAGES: 10 → 20 (better conversation coherence)
- MEMORY_RESPONSE_PREVIEW_LENGTH: 200 → 500 (richer memory storage)
- MAX_CONVERSATION_HISTORY: 50 → 100 (longer session continuity)
- search_hybrid max_results: 2 → 5 (better memory recall)
- System prompt: Now mentions tool count and flat-rate subscription
- Memory format: Changed "User (username)/Agent" to "username/Garvis"

### llm_interface.py
- Added claude_agent_sdk model (Sonnet) to defaults
- Mode-based model selection:
  * Agent SDK → Sonnet (best quality, flat-rate)
  * Direct API → Haiku (cheapest, pay-per-token)
- Updated logging to show active model

## SOUL.md Rewrite

- Added Garvis identity (name, email, role)
- Listed all 17 tools (was missing 12 tools)
- Added "Critical Behaviors" section
- Emphasized flat-rate subscription benefits
- Clear instructions to always check user profiles

## Benefits

With flat-rate Agent SDK:
-  Use Sonnet for better reasoning (was Haiku)
-  2x context messages (10 → 20)
-  2.5x memory results (2 → 5)
-  2.5x richer memory previews (200 → 500 chars)
-  Bot knows its name and all capabilities
-  Zero marginal cost for thoroughness

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 10:22:23 -07:00
ce2c384387 Migrate to Claude Agent SDK framework (v0.2.0)
BREAKING CHANGE: Replaced FastAPI server wrapper with direct Claude Agent SDK integration

## Major Changes

### Architecture
- **OLD:** Bot → FastAPI Server → claude-code-sdk → Claude
- **NEW:** Bot → Claude Agent SDK → Claude (Pro subscription OR API)
- Eliminated HTTP server overhead
- Single-process architecture

### LLM Backend (llm_interface.py)
- Implemented Claude Agent SDK as DEFAULT mode
- Added three-mode architecture:
  - agent-sdk (default) - Uses Pro subscription
  - direct-api - Pay-per-token Anthropic API
  - legacy-server - Backward compat with old setup
- Created async/sync bridge using anyio
- Preserved all existing functionality (17 tools, memory, scheduling)

### Dependencies (requirements.txt)
- Replaced: claude-code-sdk → claude-agent-sdk>=0.1.0
- Added: anyio>=4.0.0 for async bridging
- Removed: fastapi, uvicorn (no longer needed for default mode)

### New Files
- ajarbot.py - Unified launcher with pre-flight checks
- run.bat - Windows one-command launcher (auto-setup)
- pyproject.toml - Python package metadata
- MIGRATION.md - Upgrade guide from old setup
- AGENT_SDK_IMPLEMENTATION.md - Technical documentation
- QUICK_REFERENCE_AGENT_SDK.md - Quick reference card
- test_agent_sdk.py - Comprehensive test suite

### Updated Documentation
- CLAUDE_CODE_SETUP.md - Rewritten for Agent SDK
- README.md - Updated quick start for new default
- .env.example - Added AJARBOT_LLM_MODE configuration

### Deleted Files
- claude_code_server.py - Replaced by agent-sdk integration
- heartbeat.py - Superseded by scheduled_tasks.py
- pulse_brain.py - Unused in production

## Migration Path

Old setup:
1. Start FastAPI server: python claude_code_server.py
2. Start bot: python bot_runner.py
3. Set USE_CLAUDE_CODE_SERVER=true

New setup:
1. Run: run.bat (or python ajarbot.py)
   - That's it! Single command.

## Benefits

 Zero API costs (uses Claude Pro subscription)
 Simplified deployment (no separate server)
 Single-command launch (run.bat)
 Faster response times (no HTTP overhead)
 All functionality preserved (17 tools, memory, adapters)
 Backward compatible (old env vars still work)

## Compatibility

- Python 3.10+ required
- Node.js required (for Claude Code CLI bundled with SDK)
- Windows 11 tested and optimized
- All existing tools, memory system, and adapters unchanged

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 10:03:11 -07:00
a8665d8c72 Refactor: Clean up obsolete files and organize codebase structure
This commit removes deprecated modules and reorganizes code into logical directories:

Deleted files (superseded by newer systems):
- claude_code_server.py (replaced by agent-sdk direct integration)
- heartbeat.py (superseded by scheduled_tasks.py)
- pulse_brain.py (unused in production)
- config/pulse_brain_config.py (obsolete config)

Created directory structure:
- examples/ (7 example files: example_*.py, demo_*.py)
- tests/ (5 test files: test_*.py)

Updated imports:
- agent.py: Removed heartbeat module and all enable_heartbeat logic
- bot_runner.py: Removed heartbeat parameter from Agent initialization
- llm_interface.py: Updated deprecated claude_code_server message

Preserved essential files:
- hooks.py (for future use)
- adapters/skill_integration.py (for future use)
- All Google integration tools (Gmail, Calendar, Contacts)
- GLM provider code (backward compatibility)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 09:57:39 -07:00
f018800d94 Implement self-healing system Phase 1: Error capture and logging
- Add SelfHealingSystem with error observation infrastructure
- Capture errors with full context: type, message, stack trace, intent, inputs
- Log to MEMORY.md with deduplication (max 3 attempts per error signature)
- Integrate error capture in agent, tools, runtime, and scheduler
- Non-invasive: preserves all existing error handling behavior
- Foundation for future diagnosis and auto-fixing capabilities

Phase 1 of 4-phase rollout - observation only, no auto-fixing yet.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 18:03:42 -07:00
56 changed files with 9247 additions and 1508 deletions

View File

@@ -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 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 # 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
View File

@@ -42,6 +42,8 @@ Thumbs.db
*.local.json *.local.json
.env .env
.env.local .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 config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
# Memory workspace (optional - remove if you want to version control) # 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/memory_index.db
memory_workspace/users/*.md # User profiles (jordan.md, etc.) memory_workspace/users/*.md # User profiles (jordan.md, etc.)
memory_workspace/vectors.usearch 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) # User profiles (personal info)
users/ users/
@@ -60,5 +65,12 @@ usage_data.json
config/google_credentials.yaml config/google_credentials.yaml
config/google_oauth_token.json 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 # Logs
*.log *.log
logs/

256
CLAUDE_CODE_SETUP.md Normal file
View 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/)

View File

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

File diff suppressed because it is too large Load Diff

207
LOGGING.md Normal file
View 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
View 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`

View 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
```

View 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

View File

@@ -15,19 +15,18 @@ A lightweight, cost-effective AI agent framework for building proactive bots wit
## Features ## 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 - **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 - **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) - **Pulse & Brain Monitoring**: 92% cost savings with intelligent conditional monitoring (recommended)
- **Task Scheduling**: Cron-like scheduled tasks with flexible cadences - **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 - **Multi-LLM Support**: Claude (Anthropic) primary, GLM (z.ai) optional
## Quick Start ## Quick Start
**For detailed setup instructions**, see **[SETUP.md](SETUP.md)** - includes API key setup, configuration, and troubleshooting. ### Option 1: Agent SDK (Recommended - Uses Pro Subscription)
### 30-Second Quickstart
```bash ```bash
# Clone and install # Clone and install
@@ -35,18 +34,39 @@ git clone https://vulcan.apophisnetworking.net/jramos/ajarbot.git
cd ajarbot cd ajarbot
pip install -r requirements.txt pip install -r requirements.txt
# Configure (copy examples and add your API key) # Authenticate with Claude CLI (one-time setup)
cp .env.example .env claude auth login
cp config/scheduled_tasks.example.yaml config/scheduled_tasks.yaml
# Add your Anthropic API key to .env # Configure adapters
# ANTHROPIC_API_KEY=sk-ant-... 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
python example_usage.py run.bat # Windows
python ajarbot.py # Linux/Mac
``` ```
**Windows users**: Run `quick_start.bat` for automated setup ### 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
run.bat # Windows
python ajarbot.py # Linux/Mac
```
**See [CLAUDE_CODE_SETUP.md](CLAUDE_CODE_SETUP.md)** for detailed setup and mode comparison.
### Model Switching Commands ### Model Switching Commands
@@ -346,11 +366,18 @@ ajarbot/
### Environment Variables ### Environment Variables
```bash ```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-..." export ANTHROPIC_API_KEY="sk-ant-..."
# Optional # Optional: Alternative LLM
export GLM_API_KEY="..." export GLM_API_KEY="..."
# Adapter credentials (stored in config/adapters.local.yaml)
export AJARBOT_SLACK_BOT_TOKEN="xoxb-..." export AJARBOT_SLACK_BOT_TOKEN="xoxb-..."
export AJARBOT_SLACK_APP_TOKEN="xapp-..." export AJARBOT_SLACK_APP_TOKEN="xapp-..."
export AJARBOT_TELEGRAM_BOT_TOKEN="123456:ABC..." export AJARBOT_TELEGRAM_BOT_TOKEN="123456:ABC..."

205
SUB_AGENTS.md Normal file
View 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!

View File

@@ -139,11 +139,37 @@ class AdapterRuntime:
if adapter: if adapter:
await adapter.send_typing_indicator(message.channel_id) 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) # Get response from agent (synchronous call in thread)
response = await asyncio.to_thread( response = await asyncio.to_thread(
self.agent.chat, self.agent.chat,
user_message=processed_message.text, user_message=processed_message.text,
username=username, username=username,
progress_callback=progress_callback,
) )
# Apply postprocessors # Apply postprocessors
@@ -181,6 +207,17 @@ class AdapterRuntime:
except Exception as e: except Exception as e:
print(f"[Runtime] Error processing message: {e}") print(f"[Runtime] Error processing message: {e}")
traceback.print_exc() 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) await self._send_error_reply(message)
async def _send_error_reply(self, message: InboundMessage) -> None: async def _send_error_reply(self, message: InboundMessage) -> None:
@@ -206,6 +243,14 @@ class AdapterRuntime:
print("[Runtime] Starting adapter runtime...") print("[Runtime] Starting adapter runtime...")
await self.registry.start_all() 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._is_running = True
self.message_loop_task = asyncio.create_task( self.message_loop_task = asyncio.create_task(
self._process_message_queue() self._process_message_queue()

326
agent.py
View File

@@ -1,45 +1,131 @@
"""AI Agent with Memory and LLM Integration.""" """AI Agent with Memory and LLM Integration."""
import threading import threading
from typing import List, Optional import time
from typing import List, Optional, Callable
from heartbeat import Heartbeat
from hooks import HooksSystem from hooks import HooksSystem
from llm_interface import LLMInterface from llm_interface import LLMInterface
from memory_system import MemorySystem from memory_system import MemorySystem
from self_healing import SelfHealingSystem
from tools import TOOL_DEFINITIONS, execute_tool from tools import TOOL_DEFINITIONS, execute_tool
# Maximum number of recent messages to include in LLM context # Maximum number of recent messages to include in LLM context
MAX_CONTEXT_MESSAGES = 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 # 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 # 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: class Agent:
"""AI Agent with memory, LLM, heartbeat, and hooks.""" """AI Agent with memory, LLM, and hooks."""
def __init__( def __init__(
self, self,
provider: str = "claude", provider: str = "claude",
workspace_dir: str = "./memory_workspace", workspace_dir: str = "./memory_workspace",
enable_heartbeat: bool = False, is_sub_agent: bool = False,
specialist_prompt: Optional[str] = None,
) -> None: ) -> None:
self.memory = MemorySystem(workspace_dir) self.memory = MemorySystem(workspace_dir)
self.llm = LLMInterface(provider) self.llm = LLMInterface(provider)
self.hooks = HooksSystem() self.hooks = HooksSystem()
self.conversation_history: List[dict] = [] self.conversation_history: List[dict] = []
self._chat_lock = threading.Lock() 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() self.memory.sync()
self.hooks.trigger("agent", "startup", {"workspace_dir": workspace_dir}) 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 def spawn_sub_agent(
if enable_heartbeat: self,
self.heartbeat = Heartbeat(self.memory, self.llm) specialist_prompt: str,
self.heartbeat.on_alert = self._on_heartbeat_alert agent_id: Optional[str] = None,
self.heartbeat.start() 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]: def _get_context_messages(self, max_messages: int) -> List[dict]:
"""Get recent messages without breaking tool_use/tool_result pairs. """Get recent messages without breaking tool_use/tool_result pairs.
@@ -89,10 +175,6 @@ class Agent:
return result 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: def _prune_conversation_history(self) -> None:
"""Prune conversation history to prevent unbounded growth. """Prune conversation history to prevent unbounded growth.
@@ -115,13 +197,26 @@ class Agent:
self.conversation_history = self.conversation_history[start_idx:] 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. """Chat with context from memory and tool use.
Thread-safe: uses a lock to prevent concurrent modification of Thread-safe: uses a lock to prevent concurrent modification of
conversation history from multiple threads (e.g., scheduled tasks conversation history from multiple threads (e.g., scheduled tasks
and live messages). 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) # Handle model switching commands (no lock needed, read-only on history)
if user_message.lower().startswith("/model "): if user_message.lower().startswith("/model "):
model_name = user_message[7:].strip() model_name = user_message[7:].strip()
@@ -146,36 +241,160 @@ class Agent:
) )
with self._chat_lock: with self._chat_lock:
return self._chat_inner(user_message, username) 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: def _chat_inner(self, user_message: str, username: str) -> str:
"""Inner chat logic, called while holding _chat_lock.""" """Inner chat logic, called while holding _chat_lock."""
soul = self.memory.get_soul() system = self._build_system_prompt(user_message, username)
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."
)
self.conversation_history.append( self.conversation_history.append(
{"role": "user", "content": user_message} {"role": "user", "content": user_message}
) )
# Prune history to prevent unbounded growth
self._prune_conversation_history() self._prune_conversation_history()
# Tool execution loop # In Agent SDK mode, query() handles tool calls automatically via MCP.
max_iterations = 5 # Reduced from 10 to save costs # The tool loop is only needed for Direct API mode.
# Enable caching for Sonnet to save 90% on repeated system prompts 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() use_caching = "sonnet" in self.llm.model.lower()
tools_used = []
for iteration in range(max_iterations): 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) context_messages = self._get_context_messages(MAX_CONTEXT_MESSAGES)
try: try:
@@ -188,11 +407,19 @@ class Agent:
except Exception as e: except Exception as e:
error_msg = f"LLM API error: {e}" error_msg = f"LLM API error: {e}"
print(f"[Agent] {error_msg}") 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": if response.stop_reason == "end_turn":
# Extract text response
text_content = [] text_content = []
for block in response.content: for block in response.content:
if block.type == "text": if block.type == "text":
@@ -200,7 +427,6 @@ class Agent:
final_response = "\n".join(text_content) final_response = "\n".join(text_content)
# Handle empty response
if not final_response.strip(): if not final_response.strip():
final_response = "(No response generated)" final_response = "(No response generated)"
@@ -208,17 +434,16 @@ class Agent:
{"role": "assistant", "content": final_response} {"role": "assistant", "content": final_response}
) )
preview = final_response[:MEMORY_RESPONSE_PREVIEW_LENGTH] compact_summary = self.memory.compact_conversation(
self.memory.write_memory( user_message=user_message,
f"**User ({username})**: {user_message}\n" assistant_response=final_response,
f"**Agent**: {preview}...", tools_used=tools_used if tools_used else None
daily=True,
) )
self.memory.write_memory(compact_summary, daily=True)
return final_response return final_response
elif response.stop_reason == "tool_use": elif response.stop_reason == "tool_use":
# Build assistant message with tool uses
assistant_content = [] assistant_content = []
tool_uses = [] tool_uses = []
@@ -242,11 +467,11 @@ class Agent:
"content": assistant_content "content": assistant_content
}) })
# Execute tools and build tool result message
tool_results = [] tool_results = []
for tool_use in tool_uses: for tool_use in tool_uses:
result = execute_tool(tool_use.name, tool_use.input) if tool_use.name not in tools_used:
# Truncate large tool outputs to prevent token explosion tools_used.append(tool_use.name)
result = execute_tool(tool_use.name, tool_use.input, healing_system=self.healing_system)
if len(result) > 5000: if len(result) > 5000:
result = result[:5000] + "\n... (output truncated)" result = result[:5000] + "\n... (output truncated)"
print(f"[Tool] {tool_use.name}: {result[:100]}...") print(f"[Tool] {tool_use.name}: {result[:100]}...")
@@ -262,7 +487,6 @@ class Agent:
}) })
else: else:
# Unexpected stop reason
return f"Unexpected stop reason: {response.stop_reason}" return f"Unexpected stop reason: {response.stop_reason}"
return "Error: Maximum tool use iterations exceeded" return "Error: Maximum tool use iterations exceeded"
@@ -270,13 +494,9 @@ class Agent:
def switch_model(self, provider: str) -> None: def switch_model(self, provider: str) -> None:
"""Switch LLM provider.""" """Switch LLM provider."""
self.llm = LLMInterface(provider) self.llm = LLMInterface(provider)
if self.heartbeat:
self.heartbeat.llm = self.llm
def shutdown(self) -> None: def shutdown(self) -> None:
"""Cleanup and stop background services.""" """Cleanup and stop background services."""
if self.heartbeat:
self.heartbeat.stop()
self.memory.close() self.memory.close()
self.hooks.trigger("agent", "shutdown", {}) self.hooks.trigger("agent", "shutdown", {})

205
ajarbot.py Normal file
View 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()

View File

@@ -77,7 +77,6 @@ class BotRunner:
self.agent = Agent( self.agent = Agent(
provider="claude", provider="claude",
workspace_dir="./memory_workspace", workspace_dir="./memory_workspace",
enable_heartbeat=False,
) )
print("[Setup] Agent initialized") print("[Setup] Agent initialized")

View 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"

View 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

View File

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

View File

@@ -1,85 +1,63 @@
# Scheduled Tasks Configuration (EXAMPLE) # Scheduled Tasks Configuration
# Copy this to scheduled_tasks.yaml and customize with your values # 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: tasks:
# Morning briefing - sent to Slack/Telegram # Morning briefing - sent to Slack/Telegram
- name: morning-weather - name: morning-weather
prompt: | 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) Format the report as:
2. Any pending tasks from yesterday
3. Priorities for today
4. A motivational quote to start the day
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" schedule: "daily 06:00"
enabled: true enabled: true
send_to_platform: "telegram" send_to_platform: "telegram" # or "slack"
send_to_channel: "YOUR_TELEGRAM_USER_ID" # Replace with your Telegram user ID send_to_channel: "YOUR_TELEGRAM_USER_ID"
# Evening summary # Daily Zettelkasten Review
- name: evening-report - name: zettelkasten-daily-review
prompt: | prompt: |
Good evening! Time for the daily wrap-up: Time for your daily zettelkasten review! Help process fleeting notes:
1. What was accomplished today? 1. Use search_by_tags to find all notes tagged with "fleeting"
2. Any tasks still pending? 2. Show the list of fleeting notes
3. Preview of tomorrow's priorities 3. For each note, ask: "Would you like to:
4. Weather forecast for tomorrow (infer or API needed) a) Process this into a permanent note
b) Keep as fleeting for now
c) Delete (not useful)"
Keep it concise and positive. Keep it conversational and low-pressure!
schedule: "daily 18:00" schedule: "daily 20:00"
enabled: false enabled: true
send_to_platform: "telegram" send_to_platform: "telegram"
send_to_channel: "YOUR_TELEGRAM_USER_ID" send_to_channel: "YOUR_TELEGRAM_USER_ID"
# Hourly health check (no message sending) # Daily API cost report
- name: system-health-check - name: daily-cost-report
prompt: | prompt: |
Quick health check: Generate a daily API usage and cost report:
1. Are there any tasks that have been pending > 24 hours? Read the usage_data.json file to get today's API usage statistics.
2. Is the memory system healthy?
3. Any alerts or issues?
Respond with "HEALTHY" if all is well, otherwise describe the issue. Format the report with today's costs, token usage, and budget tracking.
schedule: "hourly" Warn if cumulative cost exceeds 75% of budget.
Keep it clear and actionable!
schedule: "daily 23:00"
enabled: false enabled: false
username: "health-checker" send_to_platform: "telegram"
send_to_channel: "YOUR_TELEGRAM_USER_ID"
# 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"
# Configuration notes: # Configuration notes:
# - schedule formats: # - schedule formats:

View File

@@ -5,12 +5,60 @@ tasks:
# Morning briefing - sent to Slack/Telegram # Morning briefing - sent to Slack/Telegram
- name: morning-weather - name: morning-weather
prompt: | 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" schedule: "daily 06:00"
enabled: true enabled: true
send_to_platform: "telegram" send_to_platform: "telegram"
send_to_channel: "8088983654" # Your Telegram user ID 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 # Daily API cost report
- name: daily-cost-report - name: daily-cost-report
prompt: | prompt: |

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

View File

@@ -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}")

View File

@@ -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 import os
from typing import Any, Dict, List, Optional import subprocess
import threading
from typing import Any, Dict, List, Optional, Set
import requests import requests
from anthropic import Anthropic from anthropic import Anthropic
from anthropic.types import Message
from usage_tracker import UsageTracker 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 environment variable names by provider
_API_KEY_ENV_VARS = { _API_KEY_ENV_VARS = {
"claude": "ANTHROPIC_API_KEY", "claude": "ANTHROPIC_API_KEY",
"glm": "GLM_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 by provider
_DEFAULT_MODELS = { _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": "glm-4-plus",
} }
_GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions" _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: class LLMInterface:
"""Simple LLM interface supporting Claude and GLM.""" """LLM interface supporting Claude (Agent SDK or Direct API) and GLM."""
def __init__( def __init__(
self, self,
@@ -37,51 +120,205 @@ class LLMInterface:
self.api_key = api_key or os.getenv( self.api_key = api_key or os.getenv(
_API_KEY_ENV_VARS.get(provider, ""), _API_KEY_ENV_VARS.get(provider, ""),
) )
self.model = _DEFAULT_MODELS.get(provider, "")
self.client: Optional[Anthropic] = None self.client: Optional[Anthropic] = None
# Usage tracking # Reference to the main asyncio event loop, set by the runtime.
self.tracker = UsageTracker() if track_usage else None # 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 provider == "claude":
self.client = Anthropic(api_key=self.api_key) 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( def chat(
self, self,
messages: List[Dict], messages: List[Dict],
system: Optional[str] = None, system: Optional[str] = None,
max_tokens: int = 4096, max_tokens: int = 16384,
) -> str: ) -> str:
"""Send chat request and get response. """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: Raises:
Exception: If the API call fails or returns an unexpected response. Exception: If the API call fails or returns an unexpected response.
""" """
if self.provider == "claude": if self.provider == "claude":
response = self.client.messages.create( if self.mode == "agent_sdk":
model=self.model, try:
max_tokens=max_tokens, logger.info("[LLM] chat: dispatching via Agent SDK")
system=system or "", response = self._run_async_from_thread(
messages=messages, 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}")
# Track usage elif self.mode == "direct_api":
if self.tracker and hasattr(response, "usage"): response = self.client.messages.create(
self.tracker.track(
model=self.model, model=self.model,
input_tokens=response.usage.input_tokens, max_tokens=max_tokens,
output_tokens=response.usage.output_tokens, system=system or "",
cache_creation_tokens=getattr( messages=messages,
response.usage, "cache_creation_input_tokens", 0
),
cache_read_tokens=getattr(
response.usage, "cache_read_input_tokens", 0
),
) )
if not response.content: if self.tracker and hasattr(response, "usage"):
return "" self.tracker.track(
return response.content[0].text model=self.model,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_creation_tokens=getattr(
response.usage, "cache_creation_input_tokens", 0
),
cache_read_tokens=getattr(
response.usage, "cache_read_input_tokens", 0
),
)
if not response.content:
return ""
return response.content[0].text
if self.provider == "glm": if self.provider == "glm":
payload = { payload = {
@@ -101,61 +338,340 @@ class LLMInterface:
raise ValueError(f"Unsupported provider: {self.provider}") 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( def chat_with_tools(
self, self,
messages: List[Dict], messages: List[Dict],
tools: List[Dict[str, Any]], tools: List[Dict[str, Any]],
system: Optional[str] = None, system: Optional[str] = None,
max_tokens: int = 4096, max_tokens: int = 16384,
use_cache: bool = False, use_cache: bool = False,
) -> Message: ) -> Any:
"""Send chat request with tool support. Returns full Message object. """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: 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": if self.provider != "claude":
raise ValueError("Tool use only supported for Claude provider") 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":
enable_caching = use_cache and "sonnet" in self.model.lower() # 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}")
# Structure system prompt for optimal caching elif self.mode == "direct_api":
if enable_caching and system: enable_caching = use_cache and "sonnet" in self.model.lower()
# Convert string to list format with cache control
system_blocks = [
{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"}
}
]
else:
system_blocks = system or ""
response = self.client.messages.create( if enable_caching and system:
model=self.model, system_blocks = [
max_tokens=max_tokens, {
system=system_blocks, "type": "text",
messages=messages, "text": system,
tools=tools, "cache_control": {"type": "ephemeral"}
) }
]
else:
system_blocks = system or ""
# Track usage response = self.client.messages.create(
if self.tracker and hasattr(response, "usage"):
self.tracker.track(
model=self.model, model=self.model,
input_tokens=response.usage.input_tokens, max_tokens=max_tokens,
output_tokens=response.usage.output_tokens, system=system_blocks,
cache_creation_tokens=getattr( messages=messages,
response.usage, "cache_creation_input_tokens", 0 tools=tools,
),
cache_read_tokens=getattr(
response.usage, "cache_read_input_tokens", 0
),
) )
return response if self.tracker and hasattr(response, "usage"):
self.tracker.track(
model=self.model,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_creation_tokens=getattr(
response.usage, "cache_creation_input_tokens", 0
),
cache_read_tokens=getattr(
response.usage, "cache_read_input_tokens", 0
),
)
return response
def set_model(self, model: str) -> None: def set_model(self, model: str) -> None:
"""Change the active model.""" """Change the active model."""

203
logging_config.py Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -540,6 +540,44 @@ class MemorySystem:
return sorted_results[:max_results] 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: def write_memory(self, content: str, daily: bool = True) -> None:
"""Write to memory file.""" """Write to memory file."""
if daily: if daily:

View File

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

View 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

View File

@@ -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
View 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",
]

View File

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

View File

@@ -23,3 +23,12 @@ google-auth>=2.23.0
google-auth-oauthlib>=1.1.0 google-auth-oauthlib>=1.1.0
google-auth-httplib2>=0.1.1 google-auth-httplib2>=0.1.1
google-api-python-client>=2.108.0 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
View 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 ========================================

View File

@@ -345,6 +345,17 @@ class TaskScheduler:
print(f"[Scheduler] Task failed: {task.name}") print(f"[Scheduler] Task failed: {task.name}")
print(f" Error: {e}") print(f" Error: {e}")
traceback.print_exc() 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( async def _send_to_platform(
self, task: ScheduledTask, response: str self, task: ScheduledTask, response: str

File diff suppressed because it is too large Load Diff

416
scripts/collect-remote.sh Normal file
View 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 "$@"

View 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
View 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
View 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
View 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
View File

@@ -100,6 +100,21 @@ TOOL_DEFINITIONS = [
"required": ["command"], "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 # Gmail tools
{ {
"name": "send_email", "name": "send_email",
@@ -324,30 +339,61 @@ TOOL_DEFINITIONS = [
] ]
def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> str: def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any = None) -> str:
"""Execute a tool and return the result as a string.""" """Execute a tool and return the result as a string.
This is used by the Direct API tool loop in agent.py.
In Agent SDK mode, tools are executed automatically via MCP servers
and this function is not called.
"""
import time
from logging_config import get_tool_logger
logger = get_tool_logger()
start_time = time.time()
try: try:
# File tools result_str = None
# --- File and system tools (sync handlers) ---
if tool_name == "read_file": 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": 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": elif tool_name == "edit_file":
return _edit_file( result_str = _edit_file(
tool_input["file_path"], tool_input["file_path"],
tool_input["old_text"], tool_input["old_text"],
tool_input["new_text"], tool_input["new_text"],
) )
elif tool_name == "list_directory": elif tool_name == "list_directory":
path = tool_input.get("path", ".") result_str = _list_directory(tool_input.get("path", "."))
return _list_directory(path)
elif tool_name == "run_command": elif tool_name == "run_command":
command = tool_input["command"] result_str = _run_command(
working_dir = tool_input.get("working_dir", ".") tool_input["command"],
return _run_command(command, working_dir) tool_input.get("working_dir", "."),
# Gmail tools )
# --- 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": elif tool_name == "send_email":
return _send_email( result_str = _send_email(
to=tool_input["to"], to=tool_input["to"],
subject=tool_input["subject"], subject=tool_input["subject"],
body=tool_input["body"], 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"), reply_to_message_id=tool_input.get("reply_to_message_id"),
) )
elif tool_name == "read_emails": elif tool_name == "read_emails":
return _read_emails( result_str = _read_emails(
query=tool_input.get("query", ""), query=tool_input.get("query", ""),
max_results=tool_input.get("max_results", 10), max_results=tool_input.get("max_results", 10),
include_body=tool_input.get("include_body", False), include_body=tool_input.get("include_body", False),
) )
elif tool_name == "get_email": elif tool_name == "get_email":
return _get_email( result_str = _get_email(
message_id=tool_input["message_id"], message_id=tool_input["message_id"],
format_type=tool_input.get("format", "text"), format_type=tool_input.get("format", "text"),
) )
# Calendar tools
elif tool_name == "read_calendar": elif tool_name == "read_calendar":
return _read_calendar( result_str = _read_calendar(
days_ahead=tool_input.get("days_ahead", 7), days_ahead=tool_input.get("days_ahead", 7),
calendar_id=tool_input.get("calendar_id", "primary"), calendar_id=tool_input.get("calendar_id", "primary"),
max_results=tool_input.get("max_results", 20), max_results=tool_input.get("max_results", 20),
) )
elif tool_name == "create_calendar_event": elif tool_name == "create_calendar_event":
return _create_calendar_event( result_str = _create_calendar_event(
summary=tool_input["summary"], summary=tool_input["summary"],
start_time=tool_input["start_time"], start_time=tool_input["start_time"],
end_time=tool_input["end_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"), calendar_id=tool_input.get("calendar_id", "primary"),
) )
elif tool_name == "search_calendar": elif tool_name == "search_calendar":
return _search_calendar( result_str = _search_calendar(
query=tool_input["query"], query=tool_input["query"],
calendar_id=tool_input.get("calendar_id", "primary"), calendar_id=tool_input.get("calendar_id", "primary"),
) )
# Contacts tools
elif tool_name == "create_contact": elif tool_name == "create_contact":
return _create_contact( result_str = _create_contact(
given_name=tool_input["given_name"], given_name=tool_input["given_name"],
family_name=tool_input.get("family_name", ""), family_name=tool_input.get("family_name", ""),
email=tool_input.get("email", ""), 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"), notes=tool_input.get("notes"),
) )
elif tool_name == "list_contacts": elif tool_name == "list_contacts":
return _list_contacts( result_str = _list_contacts(
max_results=tool_input.get("max_results", 100), max_results=tool_input.get("max_results", 100),
query=tool_input.get("query"), query=tool_input.get("query"),
) )
elif tool_name == "get_contact": elif tool_name == "get_contact":
return _get_contact( result_str = _get_contact(
resource_name=tool_input["resource_name"], 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: 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: 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) # 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)}" 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 # Google Tools Handlers
@@ -813,3 +1022,86 @@ def _get_contact(resource_name: str) -> str:
return "\n".join(output) return "\n".join(output)
else: else:
return f"Error getting contact: {result.get('error', 'Unknown error')}" 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