Initial commit: Ajarbot with optimizations
Features: - Multi-platform bot (Slack, Telegram) - Memory system with SQLite FTS - Tool use capabilities (file ops, commands) - Scheduled tasks system - Dynamic model switching (/sonnet, /haiku) - Prompt caching for cost optimization Optimizations: - Default to Haiku 4.5 (12x cheaper) - Reduced context: 3 messages, 2 memory results - Optimized SOUL.md (48% smaller) - Automatic caching when using Sonnet (90% savings) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
38
config/adapters.yaml
Normal file
38
config/adapters.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Adapter configuration for ajarbot
|
||||
# Copy this to adapters.local.yaml and fill in your credentials
|
||||
|
||||
adapters:
|
||||
slack:
|
||||
enabled: false
|
||||
credentials:
|
||||
# Get these from https://api.slack.com/apps
|
||||
# 1. Create a new app
|
||||
# 2. Enable Socket Mode and generate an App-Level Token (xapp-...)
|
||||
# 3. Add Bot Token Scopes: chat:write, channels:history, groups:history, im:history, mpim:history
|
||||
# 4. Install app to workspace to get Bot User OAuth Token (xoxb-...)
|
||||
bot_token: "xoxb-YOUR-BOT-TOKEN"
|
||||
app_token: "xapp-YOUR-APP-TOKEN"
|
||||
settings:
|
||||
# Optional: Auto-react to messages with emoji
|
||||
auto_react_emoji: null # e.g., "thinking_face"
|
||||
|
||||
telegram:
|
||||
enabled: false
|
||||
credentials:
|
||||
# Get this from @BotFather on Telegram
|
||||
# 1. Message @BotFather
|
||||
# 2. Send /newbot
|
||||
# 3. Follow prompts to create bot
|
||||
# 4. Copy the token
|
||||
bot_token: "YOUR-BOT-TOKEN"
|
||||
settings:
|
||||
# Optional: Restrict bot to specific user IDs
|
||||
allowed_users: [] # e.g., [123456789, 987654321]
|
||||
# Message parsing mode
|
||||
parse_mode: "Markdown" # or "HTML"
|
||||
|
||||
# User mapping (optional)
|
||||
# Map platform user IDs to ajarbot usernames for memory system
|
||||
user_mapping:
|
||||
# slack:U12345: "alice"
|
||||
# telegram:123456789: "bob"
|
||||
165
config/config_loader.py
Normal file
165
config/config_loader.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Configuration loader for adapter system.
|
||||
|
||||
Loads from YAML with environment variable override support.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# Environment variable mappings: env var name -> (adapter, credential key)
|
||||
_ENV_OVERRIDES = {
|
||||
"AJARBOT_SLACK_BOT_TOKEN": ("slack", "bot_token"),
|
||||
"AJARBOT_SLACK_APP_TOKEN": ("slack", "app_token"),
|
||||
"AJARBOT_TELEGRAM_BOT_TOKEN": ("telegram", "bot_token"),
|
||||
}
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""Load adapter configuration from YAML files with env var support."""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None) -> None:
|
||||
if config_dir is None:
|
||||
config_dir = str(Path(__file__).parent)
|
||||
self.config_dir = Path(config_dir)
|
||||
self.config: Dict[str, Any] = {}
|
||||
|
||||
def load(self, filename: str = "adapters.yaml") -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration from YAML file.
|
||||
|
||||
Looks for files in this order:
|
||||
1. {filename}.local.yaml (gitignored, for secrets)
|
||||
2. {filename}
|
||||
|
||||
Environment variables can override any setting:
|
||||
AJARBOT_SLACK_BOT_TOKEN -> adapters.slack.credentials.bot_token
|
||||
AJARBOT_TELEGRAM_BOT_TOKEN -> adapters.telegram.credentials.bot_token
|
||||
"""
|
||||
local_file = self.config_dir / f"{Path(filename).stem}.local.yaml"
|
||||
config_file = self.config_dir / filename
|
||||
|
||||
if local_file.exists():
|
||||
print(f"[Config] Loading from {local_file}")
|
||||
with open(local_file) as f:
|
||||
self.config = yaml.safe_load(f) or {}
|
||||
elif config_file.exists():
|
||||
print(f"[Config] Loading from {config_file}")
|
||||
with open(config_file) as f:
|
||||
self.config = yaml.safe_load(f) or {}
|
||||
else:
|
||||
print("[Config] No config file found, using defaults")
|
||||
self.config = {"adapters": {}}
|
||||
|
||||
self._apply_env_overrides()
|
||||
return self.config
|
||||
|
||||
def _apply_env_overrides(self) -> None:
|
||||
"""Apply environment variable overrides."""
|
||||
for env_var, (adapter, credential_key) in _ENV_OVERRIDES.items():
|
||||
value = os.getenv(env_var)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
adapters = self.config.setdefault("adapters", {})
|
||||
adapter_config = adapters.setdefault(adapter, {})
|
||||
credentials = adapter_config.setdefault("credentials", {})
|
||||
credentials[credential_key] = value
|
||||
print(f"[Config] Using {env_var} from environment")
|
||||
|
||||
def get_adapter_config(
|
||||
self, platform: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get configuration for a specific platform."""
|
||||
return self.config.get("adapters", {}).get(platform)
|
||||
|
||||
def is_adapter_enabled(self, platform: str) -> bool:
|
||||
"""Check if an adapter is enabled."""
|
||||
adapter_config = self.get_adapter_config(platform)
|
||||
if not adapter_config:
|
||||
return False
|
||||
return adapter_config.get("enabled", False)
|
||||
|
||||
def get_user_mapping(self) -> Dict[str, str]:
|
||||
"""Get user ID to username mapping."""
|
||||
return self.config.get("user_mapping", {})
|
||||
|
||||
def save_template(
|
||||
self, filename: str = "adapters.local.yaml"
|
||||
) -> Path:
|
||||
"""Save a template configuration file."""
|
||||
template = {
|
||||
"adapters": {
|
||||
"slack": {
|
||||
"enabled": False,
|
||||
"credentials": {
|
||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||
},
|
||||
"settings": {},
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": False,
|
||||
"credentials": {
|
||||
"bot_token": "YOUR-BOT-TOKEN",
|
||||
},
|
||||
"settings": {
|
||||
"allowed_users": [],
|
||||
"parse_mode": "Markdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
"user_mapping": {},
|
||||
}
|
||||
|
||||
output_path = self.config_dir / filename
|
||||
with open(output_path, "w") as f:
|
||||
yaml.dump(
|
||||
template, f, default_flow_style=False, sort_keys=False
|
||||
)
|
||||
|
||||
print(f"[Config] Template saved to {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
||||
loader = ConfigLoader()
|
||||
path = loader.save_template()
|
||||
print(f"\nConfiguration template created at: {path}")
|
||||
print(
|
||||
"\nEdit this file with your credentials, "
|
||||
"then set enabled: true for each adapter you want to use."
|
||||
)
|
||||
else:
|
||||
loader = ConfigLoader()
|
||||
config = loader.load()
|
||||
|
||||
# Redact credentials before printing
|
||||
def redact_credentials(data):
|
||||
"""Redact sensitive credential values."""
|
||||
if isinstance(data, dict):
|
||||
redacted = {}
|
||||
for key, value in data.items():
|
||||
if key == "credentials" and isinstance(value, dict):
|
||||
redacted[key] = {
|
||||
k: f"{str(v)[:4]}****{str(v)[-4:]}" if v else None
|
||||
for k, v in value.items()
|
||||
}
|
||||
elif isinstance(value, (dict, list)):
|
||||
redacted[key] = redact_credentials(value)
|
||||
else:
|
||||
redacted[key] = value
|
||||
return redacted
|
||||
elif isinstance(data, list):
|
||||
return [redact_credentials(item) for item in data]
|
||||
return data
|
||||
|
||||
safe_config = redact_credentials(config)
|
||||
print("\nLoaded configuration (credentials redacted):")
|
||||
print(yaml.dump(safe_config, default_flow_style=False))
|
||||
307
config/pulse_brain_config.py
Normal file
307
config/pulse_brain_config.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
90
config/scheduled_tasks.example.yaml
Normal file
90
config/scheduled_tasks.example.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
# Scheduled Tasks Configuration (EXAMPLE)
|
||||
# Copy this to scheduled_tasks.yaml and customize with your values
|
||||
|
||||
tasks:
|
||||
# Morning briefing - sent to Slack/Telegram
|
||||
- name: morning-weather
|
||||
prompt: |
|
||||
Good morning! Please provide a weather report and daily briefing:
|
||||
|
||||
1. Current weather (you can infer or say you need an API key)
|
||||
2. Any pending tasks from yesterday
|
||||
3. Priorities for today
|
||||
4. A motivational quote to start the day
|
||||
|
||||
Keep it brief and friendly.
|
||||
schedule: "daily 06:00"
|
||||
enabled: true
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID" # Replace with your Telegram user ID
|
||||
|
||||
# Evening summary
|
||||
- name: evening-report
|
||||
prompt: |
|
||||
Good evening! Time for the daily wrap-up:
|
||||
|
||||
1. What was accomplished today?
|
||||
2. Any tasks still pending?
|
||||
3. Preview of tomorrow's priorities
|
||||
4. Weather forecast for tomorrow (infer or API needed)
|
||||
|
||||
Keep it concise and positive.
|
||||
schedule: "daily 18:00"
|
||||
enabled: false
|
||||
send_to_platform: "telegram"
|
||||
send_to_channel: "YOUR_TELEGRAM_USER_ID"
|
||||
|
||||
# Hourly health check (no message sending)
|
||||
- name: system-health-check
|
||||
prompt: |
|
||||
Quick health check:
|
||||
|
||||
1. Are there any tasks that have been pending > 24 hours?
|
||||
2. Is the memory system healthy?
|
||||
3. Any alerts or issues?
|
||||
|
||||
Respond with "HEALTHY" if all is well, otherwise describe the issue.
|
||||
schedule: "hourly"
|
||||
enabled: false
|
||||
username: "health-checker"
|
||||
|
||||
# Weekly review on Friday
|
||||
- name: weekly-summary
|
||||
prompt: |
|
||||
It's Friday! Time for the weekly review:
|
||||
|
||||
1. Major accomplishments this week
|
||||
2. Challenges faced and lessons learned
|
||||
3. Key metrics (tasks completed, etc.)
|
||||
4. Goals for next week
|
||||
5. Team shoutouts (if applicable)
|
||||
|
||||
Make it comprehensive but engaging.
|
||||
schedule: "weekly fri 17:00"
|
||||
enabled: false
|
||||
send_to_platform: "slack"
|
||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
||||
|
||||
# Custom: Midday standup
|
||||
- name: midday-standup
|
||||
prompt: |
|
||||
Midday check-in! Quick standup report:
|
||||
|
||||
1. Morning accomplishments
|
||||
2. Current focus
|
||||
3. Any blockers?
|
||||
4. Afternoon plan
|
||||
|
||||
Keep it brief - standup style.
|
||||
schedule: "daily 12:00"
|
||||
enabled: false
|
||||
send_to_platform: "slack"
|
||||
send_to_channel: "YOUR_SLACK_CHANNEL_ID"
|
||||
|
||||
# Configuration notes:
|
||||
# - schedule formats:
|
||||
# - "hourly" - Every hour on the hour
|
||||
# - "daily HH:MM" - Every day at specified time (24h format)
|
||||
# - "weekly day HH:MM" - Every week on specified day (mon, tue, wed, thu, fri, sat, sun)
|
||||
# - send_to_platform: null = don't send to messaging (only log)
|
||||
# - username: Agent memory username to use for this task
|
||||
Reference in New Issue
Block a user