#!/usr/bin/env python3 """ Aetheel โ€” Main Entry Point ============================ Starts the AI assistant with multi-channel adapters, memory, skills, scheduled tasks, and subagent support. Usage: python main.py Start with Slack + AI handler python main.py --telegram Also enable Telegram adapter python main.py --discord Also enable Discord adapter python main.py --claude Use Claude Code runtime python main.py --test Echo handler for testing python main.py --model anthropic/claude-sonnet-4-20250514 python main.py --log DEBUG Debug logging """ import argparse import asyncio import logging import os import re import sys import threading from datetime import datetime from dotenv import load_dotenv # Load .env file (secrets only โ€” config comes from ~/.aetheel/config.json) load_dotenv() from adapters.base import BaseAdapter, IncomingMessage from adapters.slack_adapter import SlackAdapter from agent.claude_runtime import ClaudeCodeConfig, ClaudeCodeRuntime from agent.opencode_runtime import ( AgentResponse, OpenCodeConfig, OpenCodeRuntime, RuntimeMode, build_aetheel_system_prompt, ) from agent.subagent import SubagentManager from config import AetheelConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH from heartbeat import HeartbeatRunner from hooks import HookManager, HookEvent from memory import MemoryManager from memory.types import MemoryConfig from scheduler import Scheduler from scheduler.store import ScheduledJob from skills import SkillsManager logger = logging.getLogger("aetheel") # Type alias for either runtime AnyRuntime = OpenCodeRuntime | ClaudeCodeRuntime # Global instances (initialized in main) _runtime: AnyRuntime | None = None _memory: MemoryManager | None = None _skills: SkillsManager | None = None _scheduler: Scheduler | None = None _subagent_mgr: SubagentManager | None = None _heartbeat: HeartbeatRunner | None = None _hook_mgr: HookManager | None = None _webhook_receiver = None # WebhookReceiver | None _adapters: dict[str, BaseAdapter] = {} # source_name -> adapter # Runtime config (stored for subagent factory) _use_claude: bool = False _cli_args: argparse.Namespace | None = None # Usage tracking _usage_stats: dict = { "total_requests": 0, "total_cost_usd": 0.0, "total_duration_ms": 0, "requests_by_engine": {"opencode": 0, "claude": 0}, "cost_by_engine": {"opencode": 0.0, "claude": 0.0}, "rate_limit_hits": 0, "failovers": 0, "last_rate_limit": None, # ISO timestamp "session_start": datetime.now().isoformat(), } # Regex for parsing action tags from AI responses _ACTION_RE = re.compile(r"\[ACTION:remind\|(\d+)\|(.+?)\]", re.DOTALL) _CRON_RE = re.compile(r"\[ACTION:cron\|([\d\*/,\- ]+)\|(.+?)\]", re.DOTALL) _SPAWN_RE = re.compile(r"\[ACTION:spawn\|(.+?)\]", re.DOTALL) # --------------------------------------------------------------------------- # Message Handlers # --------------------------------------------------------------------------- def echo_handler(msg: IncomingMessage) -> str: """Simple echo handler for testing.""" response_lines = [ f"๐Ÿ‘‹ *Aetheel received your message!*", "", f"๐Ÿ“ *Text:* {msg.text}", f"๐Ÿ‘ค *From:* {msg.user_name} (`{msg.user_id}`)", f"๐Ÿ“ *Channel:* {msg.channel_name} (`{msg.channel_id}`)", f"๐Ÿ’ฌ *Source:* {msg.source}", f"๐Ÿงต *ConvID:* `{msg.conversation_id[:15]}...`", f"๐Ÿ• *Time:* {msg.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}", "", f"_This is an echo response from the Aetheel test handler._", ] return "\n".join(response_lines) def _build_context(msg: IncomingMessage) -> str: """ Build full context to inject into the system prompt. Combines: - Identity files (SOUL.md, USER.md, MEMORY.md) - Relevant memory search results - Relevant skills for this message - Available skills summary """ global _memory, _skills sections: list[str] = [] # โ”€โ”€ Identity: SOUL.md โ”€โ”€ if _memory: soul = _memory.read_soul() if soul: sections.append(f"# Your Identity (SOUL.md)\n\n{soul}") # โ”€โ”€ User profile: USER.md โ”€โ”€ user = _memory.read_user() if user: sections.append(f"# About the User (USER.md)\n\n{user}") # โ”€โ”€ Long-term memory: MEMORY.md โ”€โ”€ ltm = _memory.read_long_term_memory() if ltm: sections.append(f"# Long-Term Memory (MEMORY.md)\n\n{ltm}") # โ”€โ”€ Relevant memory search results โ”€โ”€ try: results = asyncio.run(_memory.search(msg.text, max_results=3, min_score=0.2)) if results: snippets = [] for r in results: if r.path in ("SOUL.md", "USER.md", "MEMORY.md"): continue snippets.append( f"**{r.path}** (lines {r.start_line}-{r.end_line}, " f"relevance {r.score:.0%}):\n{r.snippet[:500]}" ) if snippets: sections.append( "# Relevant Memory Context\n\n" + "\n\n---\n\n".join(snippets) ) except Exception as e: logger.debug(f"Memory search failed: {e}") # โ”€โ”€ Skills context โ”€โ”€ if _skills: # Inject matching skill instructions skill_context = _skills.get_context(msg.text) if skill_context: sections.append(skill_context) # Always show available skills summary skills_summary = _skills.get_all_context() if skills_summary: sections.append(skills_summary) return "\n\n---\n\n".join(sections) def ai_handler(msg: IncomingMessage) -> str: """ AI-powered handler โ€” the heart of Aetheel. Flow: Message โ†’ context (memory + skills) โ†’ system prompt โ†’ runtime.chat() โ†’ action tags โ†’ session log โ†’ response """ global _runtime, _memory, _scheduler if _runtime is None: return "โš ๏ธ AI runtime not initialized. Please restart the service." text_lower = msg.text.lower().strip() # Built-in commands (bypass AI) # Commands work with or without "/" prefix. # In Slack channels, "/" is intercepted as a native slash command, # so users should use the prefix-free form (e.g. "engine claude"). # In DMs and other adapters, both forms work. cmd = text_lower.lstrip("/") if cmd in ("status", "ping"): return _format_status() if cmd == "help": return _format_help() if cmd == "time": return f"๐Ÿ• Server time: *{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*" if cmd in ("sessions",): return _format_sessions() # Cron management commands if cmd.startswith("cron"): return _handle_cron_command(cmd) # Reload config and skills if cmd in ("reload",): cfg = load_config() if _skills: _skills.reload() if _hook_mgr: _hook_mgr.trigger(HookEvent(type="command", action="reload")) return "๐Ÿ”„ Config and skills reloaded." # List active subagents if cmd in ("subagents",): return _format_subagents() # Runtime management commands if cmd.startswith("engine") or cmd.startswith("runtime"): return _handle_engine_command(msg.text.strip().lstrip("/")) if cmd.startswith("model"): return _handle_model_command(msg.text.strip().lstrip("/")) if cmd.startswith("provider"): return _handle_provider_command(msg.text.strip().lstrip("/")) if cmd.startswith("config"): return _handle_config_command(msg.text.strip().lstrip("/")) if cmd.startswith("usage"): return _handle_usage_command() if cmd.startswith("mcp"): return _handle_mcp_command(msg.text.strip().lstrip("/")) if cmd.startswith("skill"): return _handle_skill_command(msg.text.strip().lstrip("/")) # Build context from memory + skills context = _build_context(msg) # Route to AI via runtime system_prompt = build_aetheel_system_prompt( user_name=msg.user_name, channel_name=msg.channel_name, is_dm=msg.is_dm, extra_context=context, ) response = _runtime.chat( message=msg.text, conversation_id=msg.conversation_id, system_prompt=system_prompt, ) # Track usage stats _track_usage(response) # Rate limit detection + auto-failover if response.rate_limited: failover_response = _handle_rate_limit(msg, system_prompt, response) if failover_response is not None: return failover_response if not response.ok: error_msg = response.error or "Unknown error" logger.error(f"AI error: {error_msg}") if "not found" in error_msg.lower() or "not installed" in error_msg.lower(): return ( "โš ๏ธ AI CLI is not available.\n" "Check the runtime installation docs." ) if "timeout" in error_msg.lower(): return ( "โณ The AI took too long to respond. " "Try a shorter or simpler question." ) return f"โš ๏ธ AI error: {error_msg[:200]}" logger.info( f"๐Ÿค– AI response: {len(response.text)} chars, " f"{response.duration_ms}ms" f"{', $' + str(response.usage.get('cost_usd', 0)) if response.usage else ''}" ) # Parse and execute action tags (reminders, cron, spawn) reply_text = _process_action_tags(response.text, msg) # Log conversation to memory session log if _memory: try: channel = "dm" if msg.is_dm else msg.channel_name or msg.source _memory.log_session( f"**User ({msg.user_name}):** {msg.text}\n\n" f"**Aetheel:** {reply_text}", channel=channel, ) except Exception as e: logger.debug(f"Session logging failed: {e}") return reply_text # --------------------------------------------------------------------------- # Action Tag Processing # --------------------------------------------------------------------------- def _process_action_tags(text: str, msg: IncomingMessage) -> str: """ Parse and execute action tags from the AI response. Supports: [ACTION:remind||] โ†’ one-shot reminder [ACTION:cron||] โ†’ recurring cron job [ACTION:spawn|] โ†’ background subagent """ cleaned = text # โ”€โ”€ Remind tags (one-shot) โ”€โ”€ for match in _ACTION_RE.finditer(text): minutes_str, reminder_msg = match.group(1), match.group(2) try: minutes = int(minutes_str) if _scheduler: _scheduler.add_once( delay_minutes=minutes, prompt=reminder_msg.strip(), channel_id=msg.channel_id, channel_type=msg.source, thread_id=msg.raw_event.get("thread_id"), user_name=msg.user_name, ) logger.info( f"โฐ Reminder scheduled: '{reminder_msg.strip()[:50]}' " f"in {minutes} min for {msg.source}/{msg.channel_name}" ) except Exception as e: logger.warning(f"Failed to schedule reminder: {e}") cleaned = cleaned.replace(match.group(0), "").strip() # โ”€โ”€ Cron tags (recurring) โ”€โ”€ for match in _CRON_RE.finditer(text): cron_expr, cron_prompt = match.group(1).strip(), match.group(2).strip() try: if _scheduler: job_id = _scheduler.add_cron( cron_expr=cron_expr, prompt=cron_prompt, channel_id=msg.channel_id, channel_type=msg.source, thread_id=msg.raw_event.get("thread_id"), user_name=msg.user_name, ) logger.info( f"๐Ÿ”„ Cron scheduled: '{cron_prompt[:50]}' ({cron_expr}) " f"job_id={job_id}" ) except Exception as e: logger.warning(f"Failed to schedule cron job: {e}") cleaned = cleaned.replace(match.group(0), "").strip() # โ”€โ”€ Spawn tags (subagent) โ”€โ”€ for match in _SPAWN_RE.finditer(text): spawn_task = match.group(1).strip() try: if _subagent_mgr: task_id = _subagent_mgr.spawn( task=spawn_task, channel_id=msg.channel_id, channel_type=msg.source, thread_id=msg.raw_event.get("thread_id"), user_name=msg.user_name, ) logger.info( f"๐Ÿš€ Subagent spawned: '{spawn_task[:50]}' " f"task_id={task_id}" ) except Exception as e: logger.warning(f"Failed to spawn subagent: {e}") cleaned = cleaned.replace(match.group(0), "").strip() return cleaned # --------------------------------------------------------------------------- # Cron Management Commands # --------------------------------------------------------------------------- def _handle_cron_command(text: str) -> str: """Handle /cron subcommands.""" global _scheduler if not _scheduler: return "โš ๏ธ Scheduler not initialized." parts = text.strip().split(maxsplit=2) if len(parts) < 2 or parts[1] == "list": jobs = _scheduler.list_jobs() if not jobs: return "๐Ÿ“‹ No scheduled jobs." lines = ["๐Ÿ“‹ *Scheduled Jobs:*\n"] for job in jobs: kind = f"๐Ÿ”„ `{job.cron_expr}`" if job.is_recurring else "โฐ one-shot" lines.append( f"โ€ข `{job.id}` โ€” {kind} โ€” {job.prompt[:60]}" ) return "\n".join(lines) if parts[1] == "remove" and len(parts) >= 3: job_id = parts[2].strip() if _scheduler.remove(job_id): return f"โœ… Job `{job_id}` removed." return f"โš ๏ธ Job `{job_id}` not found." return ( "Usage: `cron list` or `cron remove `" ) # --------------------------------------------------------------------------- # Runtime / Model / Provider Management Commands # --------------------------------------------------------------------------- def _handle_engine_command(text: str) -> str: """ Handle /engine (or /runtime) commands. /engine Show current engine /engine opencode Switch to OpenCode /engine claude Switch to Claude Code """ global _runtime, _use_claude parts = text.strip().split(maxsplit=1) if len(parts) < 2: engine = "claude" if _use_claude else "opencode" status = _runtime.get_status() if _runtime else {} model = status.get("model", "default") provider = status.get("provider", "auto") return ( f"๐Ÿง  *Current Engine:* `{engine}`\n" f"โ€ข *Model:* `{model}`\n" f"โ€ข *Provider:* `{provider}`\n" f"\n" f"Switch with: `/engine opencode` or `/engine claude`" ) target = parts[1].strip().lower() if target not in ("opencode", "claude"): return "Usage: `/engine opencode` or `/engine claude`" want_claude = target == "claude" if want_claude == _use_claude: return f"Already using `{target}`." # Hot-swap the runtime try: _use_claude = want_claude cfg = load_config() cfg.runtime.engine = target if want_claude: new_config = ClaudeCodeConfig( model=cfg.claude.model, timeout_seconds=cfg.claude.timeout_seconds, max_turns=cfg.claude.max_turns, no_tools=cfg.claude.no_tools, allowed_tools=cfg.claude.allowed_tools, ) _runtime = ClaudeCodeRuntime(new_config) else: new_config = OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=cfg.runtime.model, provider=cfg.runtime.provider, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, ) _runtime = OpenCodeRuntime(new_config) # Persist to config.json _update_config_file({"runtime": {"engine": target}}) status = _runtime.get_status() return ( f"โœ… Switched to `{target}`\n" f"โ€ข *Model:* `{status.get('model', 'default')}`\n" f"โ€ข *Provider:* `{status.get('provider', 'auto')}`" ) except Exception as e: logger.error(f"Engine switch failed: {e}", exc_info=True) return f"โš ๏ธ Failed to switch engine: {e}" def _handle_model_command(text: str) -> str: """ Handle /model commands. /model Show current model /model Switch model (hot-swap) /model anthropic/claude-sonnet-4-20250514 /model openai/gpt-4o """ global _runtime, _use_claude parts = text.strip().split(maxsplit=1) if len(parts) < 2: status = _runtime.get_status() if _runtime else {} return ( f"๐Ÿง  *Current Model:* `{status.get('model', 'default')}`\n" f"โ€ข *Engine:* `{'claude' if _use_claude else 'opencode'}`\n" f"โ€ข *Provider:* `{status.get('provider', 'auto')}`\n" f"\n" f"Switch with: `/model `\n" f"Examples:\n" f"โ€ข `/model anthropic/claude-sonnet-4-20250514`\n" f"โ€ข `/model openai/gpt-4o`\n" f"โ€ข `/model google/gemini-2.5-pro`" ) new_model = parts[1].strip() if not new_model: return "Usage: `/model `" try: cfg = load_config() if _use_claude: cfg.claude.model = new_model new_config = ClaudeCodeConfig( model=new_model, timeout_seconds=cfg.claude.timeout_seconds, max_turns=cfg.claude.max_turns, no_tools=cfg.claude.no_tools, allowed_tools=cfg.claude.allowed_tools, ) _runtime = ClaudeCodeRuntime(new_config) _update_config_file({"claude": {"model": new_model}}) else: cfg.runtime.model = new_model new_config = OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=new_model, provider=cfg.runtime.provider, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, ) _runtime = OpenCodeRuntime(new_config) _update_config_file({"runtime": {"model": new_model}}) return f"โœ… Model switched to `{new_model}`" except Exception as e: logger.error(f"Model switch failed: {e}", exc_info=True) return f"โš ๏ธ Failed to switch model: {e}" def _handle_provider_command(text: str) -> str: """ Handle /provider commands (OpenCode only). /provider Show current provider /provider anthropic Switch provider /provider openai Switch provider """ global _runtime, _use_claude if _use_claude: return "Provider selection is only available with the OpenCode engine. Claude Code always uses Anthropic." parts = text.strip().split(maxsplit=1) if len(parts) < 2: status = _runtime.get_status() if _runtime else {} return ( f"๐Ÿ”Œ *Current Provider:* `{status.get('provider', 'auto')}`\n" f"\n" f"Switch with: `/provider `\n" f"Examples: `anthropic`, `openai`, `google`, `auto`" ) new_provider = parts[1].strip().lower() try: cfg = load_config() cfg.runtime.provider = new_provider if new_provider != "auto" else None new_config = OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=cfg.runtime.model, provider=new_provider if new_provider != "auto" else None, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, ) _runtime = OpenCodeRuntime(new_config) _update_config_file({"runtime": {"provider": new_provider}}) return f"โœ… Provider switched to `{new_provider}`" except Exception as e: logger.error(f"Provider switch failed: {e}", exc_info=True) return f"โš ๏ธ Failed to switch provider: {e}" def _handle_config_command(text: str) -> str: """ Handle /config commands โ€” view and edit config from chat. /config Show key settings /config show Show full config /config set Set a config value """ import json as _json parts = text.strip().split(maxsplit=2) subcommand = parts[1] if len(parts) > 1 else "summary" if subcommand == "show": try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = _json.load(f) formatted = _json.dumps(data, indent=2) if len(formatted) > 3000: formatted = formatted[:3000] + "\n... (truncated)" return f"```\n{formatted}\n```" except Exception as e: return f"โš ๏ธ Failed to read config: {e}" elif subcommand == "set" and len(parts) >= 3: rest = parts[2].strip() kv_parts = rest.split(maxsplit=1) if len(kv_parts) < 2: return "Usage: `/config set runtime.model anthropic/claude-sonnet-4-20250514`" key_path = kv_parts[0] value_str = kv_parts[1] # Parse value (handle booleans, numbers, null, strings) try: value = _json.loads(value_str) except _json.JSONDecodeError: value = value_str keys = key_path.split(".") if len(keys) < 2: return "Use dotted notation: `/config set runtime.model `" update = {} current = update for k in keys[:-1]: current[k] = {} current = current[k] current[keys[-1]] = value try: _update_config_file(update) return f"โœ… Set `{key_path}` = `{value_str}`\nRun `/reload` to apply changes." except Exception as e: return f"โš ๏ธ Failed to update config: {e}" else: cfg = load_config() engine = cfg.runtime.engine model = cfg.claude.model if engine == "claude" else cfg.runtime.model provider = cfg.runtime.provider if engine == "opencode" else "anthropic" lines = [ "โš™๏ธ *Configuration Summary*", "", f"โ€ข *Engine:* `{engine}`", f"โ€ข *Model:* `{model or 'default'}`", f"โ€ข *Provider:* `{provider or 'auto'}`", f"โ€ข *Mode:* `{cfg.runtime.mode}`", f"โ€ข *Timeout:* `{cfg.runtime.timeout_seconds}s`", "", f"โ€ข *Slack:* {'โœ…' if cfg.slack.enabled else 'โŒ'}", f"โ€ข *Discord:* {'โœ…' if cfg.discord.enabled else 'โŒ'}", f"โ€ข *Telegram:* {'โœ…' if cfg.telegram.enabled else 'โŒ'}", f"โ€ข *WebChat:* {'โœ…' if cfg.webchat.enabled else 'โŒ'}", f"โ€ข *Webhooks:* {'โœ…' if cfg.webhooks.enabled else 'โŒ'}", "", "Use `/config show` for full config, `/config set ` to edit.", "Use `/engine`, `/model`, `/provider` for live switching.", ] return "\n".join(lines) def _update_config_file(updates: dict) -> None: """ Merge updates into ~/.aetheel/config.json. Deep merge so partial updates don't clobber existing keys. """ import json as _json data = {} if os.path.isfile(CONFIG_PATH): with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = _json.load(f) def _deep_merge(base: dict, overlay: dict) -> dict: for key, value in overlay.items(): if key in base and isinstance(base[key], dict) and isinstance(value, dict): _deep_merge(base[key], value) else: base[key] = value return base _deep_merge(data, updates) with open(CONFIG_PATH, "w", encoding="utf-8") as f: _json.dump(data, f, indent=2) logger.info(f"Config updated: {list(updates.keys())}") # --------------------------------------------------------------------------- # Usage Tracking, Rate Limit Failover, Notifications # --------------------------------------------------------------------------- def _track_usage(response: AgentResponse) -> None: """Track usage stats from an AI response.""" global _usage_stats engine = "claude" if _use_claude else "opencode" _usage_stats["total_requests"] += 1 _usage_stats["requests_by_engine"][engine] = ( _usage_stats["requests_by_engine"].get(engine, 0) + 1 ) _usage_stats["total_duration_ms"] += response.duration_ms if response.usage: cost = response.usage.get("cost_usd", 0) or 0 _usage_stats["total_cost_usd"] += cost _usage_stats["cost_by_engine"][engine] = ( _usage_stats["cost_by_engine"].get(engine, 0) + cost ) if response.rate_limited: _usage_stats["rate_limit_hits"] += 1 _usage_stats["last_rate_limit"] = datetime.now().isoformat() def _handle_rate_limit( msg: IncomingMessage, system_prompt: str, original_response: AgentResponse, ) -> str | None: """ Handle a rate-limited response. Attempts auto-failover to the other engine and notifies the user. Returns the failover response text, or None if failover isn't possible. """ global _runtime, _use_claude, _usage_stats current_engine = "claude" if _use_claude else "opencode" other_engine = "opencode" if _use_claude else "claude" logger.warning( f"Rate limit hit on {current_engine}: {original_response.error}" ) # Notify the user about the rate limit notify_text = ( f"โš ๏ธ *Rate limit reached on {current_engine}*\n" f"{original_response.error or 'Usage limit exceeded.'}" ) # Try auto-failover to the other engine cfg = load_config() failover_runtime = None try: if _use_claude: # Failover: Claude โ†’ OpenCode failover_runtime = OpenCodeRuntime(OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=cfg.runtime.model, provider=cfg.runtime.provider, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, )) else: # Failover: OpenCode โ†’ Claude failover_runtime = ClaudeCodeRuntime(ClaudeCodeConfig( model=cfg.claude.model, timeout_seconds=cfg.claude.timeout_seconds, max_turns=cfg.claude.max_turns, no_tools=cfg.claude.no_tools, allowed_tools=cfg.claude.allowed_tools, )) except Exception as e: logger.warning(f"Failover engine ({other_engine}) not available: {e}") return f"{notify_text}\n\n{other_engine} is not available for failover." # Attempt the request on the failover engine logger.info(f"Attempting failover: {current_engine} โ†’ {other_engine}") try: failover_response = failover_runtime.chat( message=msg.text, conversation_id=msg.conversation_id, system_prompt=system_prompt, ) _track_usage(failover_response) if failover_response.ok: # Failover succeeded โ€” switch the active runtime _runtime = failover_runtime _use_claude = not _use_claude _usage_stats["failovers"] += 1 engine_name = "claude" if _use_claude else "opencode" logger.info(f"Failover successful โ†’ now using {engine_name}") # Process action tags on the failover response reply_text = _process_action_tags(failover_response.text, msg) return ( f"{notify_text}\n" f"๐Ÿ”„ Auto-switched to *{other_engine}*.\n\n" f"{reply_text}" ) else: # Failover also failed return ( f"{notify_text}\n" f"Failover to {other_engine} also failed: " f"{failover_response.error or 'Unknown error'}" ) except Exception as e: logger.error(f"Failover request failed: {e}", exc_info=True) return f"{notify_text}\nFailover to {other_engine} failed: {e}" def _handle_usage_command() -> str: """ Handle the `usage` command โ€” show LLM usage stats. """ global _usage_stats total = _usage_stats["total_requests"] cost = _usage_stats["total_cost_usd"] duration = _usage_stats["total_duration_ms"] rate_limits = _usage_stats["rate_limit_hits"] failovers = _usage_stats["failovers"] started = _usage_stats["session_start"] lines = [ "๐Ÿ“Š *Usage Stats* (since last restart)", "", f"โ€ข *Total Requests:* {total}", f"โ€ข *Total Cost:* ${cost:.4f}", f"โ€ข *Total Duration:* {duration / 1000:.1f}s", f"โ€ข *Rate Limit Hits:* {rate_limits}", f"โ€ข *Auto-Failovers:* {failovers}", f"โ€ข *Session Start:* {started}", "", "*By Engine:*", ] for engine in ("opencode", "claude"): reqs = _usage_stats["requests_by_engine"].get(engine, 0) eng_cost = _usage_stats["cost_by_engine"].get(engine, 0) if reqs > 0: lines.append(f"โ€ข *{engine}:* {reqs} requests, ${eng_cost:.4f}") if _usage_stats["last_rate_limit"]: lines.append(f"\nโš ๏ธ Last rate limit: {_usage_stats['last_rate_limit']}") engine = "claude" if _use_claude else "opencode" status = _runtime.get_status() if _runtime else {} lines.extend([ "", f"*Active:* `{engine}` / `{status.get('model', 'default')}`", ]) return "\n".join(lines) # --------------------------------------------------------------------------- # MCP Server Management Commands # --------------------------------------------------------------------------- def _handle_mcp_command(text: str) -> str: """ Handle mcp commands โ€” manage MCP servers from chat. mcp list List configured servers mcp add [args...] Add a server mcp remove Remove a server mcp enable Enable a disabled server mcp disable Disable a server """ import json as _json parts = text.strip().split(maxsplit=3) subcommand = parts[1] if len(parts) > 1 else "list" cfg = load_config() if subcommand == "list": if not cfg.mcp.servers: return "๐Ÿ“ฆ No MCP servers configured.\n\nAdd one: `mcp add [args]`" lines = ["๐Ÿ“ฆ *MCP Servers:*\n"] for name, srv in cfg.mcp.servers.items(): args_str = " ".join(srv.args) if srv.args else "" lines.append(f"โ€ข `{name}` โ€” `{srv.command} {args_str}`") lines.append(f"\nManage: `mcp add`, `mcp remove `") return "\n".join(lines) elif subcommand == "add" and len(parts) >= 3: rest = text.strip().split(maxsplit=2)[2] # everything after "mcp add" add_parts = rest.split() name = add_parts[0] command = add_parts[1] if len(add_parts) > 1 else "uvx" args = add_parts[2:] if len(add_parts) > 2 else [] # Update config server_data = {"command": command, "args": args, "env": {}} _update_config_file({"mcp": {"servers": {name: server_data}}}) # Rewrite MCP config for the runtime new_cfg = load_config() write_mcp_config(new_cfg.mcp, new_cfg.memory.workspace, _use_claude) return ( f"โœ… MCP server `{name}` added\n" f"โ€ข Command: `{command} {' '.join(args)}`\n" f"Run `reload` to apply." ) elif subcommand == "remove" and len(parts) >= 3: name = parts[2].strip() try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = _json.load(f) servers = data.get("mcp", {}).get("servers", {}) if name not in servers: return f"โš ๏ธ MCP server `{name}` not found." del servers[name] with open(CONFIG_PATH, "w", encoding="utf-8") as f: _json.dump(data, f, indent=2) new_cfg = load_config() write_mcp_config(new_cfg.mcp, new_cfg.memory.workspace, _use_claude) return f"โœ… MCP server `{name}` removed." except Exception as e: return f"โš ๏ธ Failed to remove: {e}" else: return ( "Usage:\n" "โ€ข `mcp list` โ€” List servers\n" "โ€ข `mcp add [args]` โ€” Add a server\n" "โ€ข `mcp remove ` โ€” Remove a server\n" "\nExamples:\n" "โ€ข `mcp add brave-search uvx brave-search-mcp@latest`\n" "โ€ข `mcp add filesystem npx @anthropic-ai/mcp-filesystem /home/user`\n" "โ€ข `mcp add github uvx github-mcp-server`" ) # --------------------------------------------------------------------------- # Skills Management Commands # --------------------------------------------------------------------------- def _handle_skill_command(text: str) -> str: """ Handle skill commands โ€” manage skills from chat. skill list List loaded skills skill show Show a skill's content skill create Create a new skill interactively skill remove Remove a skill skill reload Reload all skills """ global _skills parts = text.strip().split(maxsplit=2) subcommand = parts[1] if len(parts) > 1 else "list" if subcommand == "list": if not _skills or not _skills.skills: return ( "๐ŸŽฏ No skills loaded.\n\n" "Create one: `skill create `\n" "Or ask me naturally: \"Create a skill for checking weather\"" ) lines = ["๐ŸŽฏ *Skills:*\n"] for s in _skills.skills: triggers = ", ".join(s.triggers[:4]) lines.append(f"โ€ข `{s.name}` โ€” {s.description}\n Triggers: {triggers}") lines.append(f"\nManage: `skill show `, `skill create `, `skill remove `") return "\n".join(lines) elif subcommand == "show" and len(parts) >= 3: name = parts[2].strip() if not _skills: return "โš ๏ธ Skills system not initialized." for s in _skills.skills: if s.name.lower() == name.lower(): content = s.body[:2000] return ( f"๐ŸŽฏ *{s.name}*\n" f"_{s.description}_\n" f"Triggers: {', '.join(s.triggers)}\n\n" f"```\n{content}\n```" ) return f"โš ๏ธ Skill `{name}` not found. Run `skill list` to see available skills." elif subcommand == "create" and len(parts) >= 3: name = parts[2].strip().lower().replace(" ", "-") cfg = load_config() workspace = os.path.expanduser(cfg.memory.workspace) skill_dir = os.path.join(workspace, "skills", name) skill_path = os.path.join(skill_dir, "SKILL.md") if os.path.exists(skill_path): return f"โš ๏ธ Skill `{name}` already exists at `{skill_path}`" os.makedirs(skill_dir, exist_ok=True) template = ( f"---\n" f"name: {name}\n" f"description: TODO โ€” describe what this skill does\n" f"triggers: [{name}]\n" f"---\n\n" f"# {name.title()} Skill\n\n" f"\n\n" f"When the user asks about {name}:\n" f"1. ...\n" f"2. ...\n" f"3. ...\n" ) with open(skill_path, "w", encoding="utf-8") as f: f.write(template) return ( f"โœ… Skill `{name}` created at:\n" f"`{skill_path}`\n\n" f"Edit the SKILL.md to add your instructions, then run `skill reload`.\n" f"Or just tell me what the skill should do and I'll write it for you." ) elif subcommand == "remove" and len(parts) >= 3: name = parts[2].strip() cfg = load_config() workspace = os.path.expanduser(cfg.memory.workspace) skill_dir = os.path.join(workspace, "skills", name) if not os.path.isdir(skill_dir): return f"โš ๏ธ Skill `{name}` not found." import shutil shutil.rmtree(skill_dir) if _skills: _skills.reload() return f"โœ… Skill `{name}` removed." elif subcommand == "reload": if _skills: loaded = _skills.reload() return f"๐Ÿ”„ Reloaded {len(loaded)} skill(s)." return "โš ๏ธ Skills system not initialized." else: return ( "Usage:\n" "โ€ข `skill list` โ€” List loaded skills\n" "โ€ข `skill show ` โ€” Show skill content\n" "โ€ข `skill create ` โ€” Create a new skill template\n" "โ€ข `skill remove ` โ€” Remove a skill\n" "โ€ข `skill reload` โ€” Reload all skills\n" "\nYou can also create skills naturally:\n" "\"Create a skill for checking stock prices\"" ) def _on_scheduled_job(job: ScheduledJob) -> None: """ Called by the scheduler when a job fires. Creates a synthetic IncomingMessage and routes it through ai_handler, then sends the response to the right channel. """ logger.info(f"๐Ÿ”” Scheduled job firing: {job.id} โ€” '{job.prompt[:50]}'") # Build a synthetic message msg = IncomingMessage( text=job.prompt, user_id="system", user_name=job.user_name or "Scheduler", channel_id=job.channel_id, channel_name=f"scheduled-{job.id}", conversation_id=f"cron-{job.id}", source=job.channel_type, is_dm=True, raw_event={"thread_id": job.thread_id}, ) # Route through the AI handler try: response = ai_handler(msg) if response: _send_to_channel( channel_id=job.channel_id, text=response, thread_id=job.thread_id, channel_type=job.channel_type, ) except Exception as e: logger.error(f"Scheduled job {job.id} handler failed: {e}", exc_info=True) # --------------------------------------------------------------------------- # Multi-Channel Send # --------------------------------------------------------------------------- def _send_to_channel( channel_id: str, text: str, thread_id: str | None, channel_type: str, ) -> None: """ Send a message to a specific channel via the right adapter. Used by the scheduler and subagent manager to route responses back to the correct platform. """ adapter = _adapters.get(channel_type) if adapter: adapter.send_message( channel_id=channel_id, text=text, thread_id=thread_id, ) else: # Fallback: try the first available adapter for a in _adapters.values(): a.send_message(channel_id=channel_id, text=text, thread_id=thread_id) break else: logger.warning( f"No adapter for '{channel_type}' โ€” cannot send message" ) # --------------------------------------------------------------------------- # Runtime Factory (for subagents) # --------------------------------------------------------------------------- def _make_runtime() -> AnyRuntime: """Create a fresh runtime instance (used by subagent manager).""" global _use_claude, _cli_args cfg = load_config() if _cli_args and _cli_args.model: cfg.runtime.model = _cli_args.model cfg.claude.model = _cli_args.model if _use_claude: config = ClaudeCodeConfig( model=cfg.claude.model, timeout_seconds=cfg.claude.timeout_seconds, max_turns=cfg.claude.max_turns, no_tools=cfg.claude.no_tools, allowed_tools=cfg.claude.allowed_tools, ) return ClaudeCodeRuntime(config) else: config = OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=cfg.runtime.model, provider=cfg.runtime.provider, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, ) return OpenCodeRuntime(config) # --------------------------------------------------------------------------- # Formatting Helpers # --------------------------------------------------------------------------- def _format_status() -> str: """Format the /status response with runtime info.""" global _runtime, _scheduler, _skills, _subagent_mgr lines = [ "๐ŸŸข *Aetheel is online*", "", ] if _runtime: status = _runtime.get_status() engine = "claude" if _use_claude else "opencode" lines.extend([ f"โ€ข *Engine:* {engine}", f"โ€ข *Mode:* {status['mode']}", f"โ€ข *Model:* {status['model']}", f"โ€ข *Provider:* {status['provider']}", f"โ€ข *Active Sessions:* {status['active_sessions']}", f"โ€ข *Live Sessions:* {status.get('live_sessions', 0)}", ]) # Adapter status if _adapters: adapter_names = ", ".join(_adapters.keys()) lines.append(f"โ€ข *Channels:* {adapter_names}") # Skills status if _skills: skill_count = len(_skills.skills) lines.append(f"โ€ข *Skills Loaded:* {skill_count}") # Scheduler status if _scheduler: jobs = _scheduler.list_jobs() lines.append(f"โ€ข *Scheduled Jobs:* {len(jobs)}") # Subagents status if _subagent_mgr: active = _subagent_mgr.list_active() lines.append(f"โ€ข *Active Subagents:* {len(active)}") lines.extend([ "", f"โ€ข *Time:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ]) return "\n".join(lines) def _format_help() -> str: """Format the /help response.""" return ( "๐Ÿฆพ *Aetheel โ€” AI-Powered Assistant*\n" "\n" "*Commands:* (type as a regular message, no `/` needed)\n" "\n" "*General:*\n" "โ€ข `status` โ€” Bot and runtime status\n" "โ€ข `help` โ€” This help message\n" "โ€ข `time` โ€” Server time\n" "โ€ข `sessions` โ€” Active session count\n" "โ€ข `reload` โ€” Reload config and skills\n" "\n" "*Runtime:*\n" "โ€ข `engine` โ€” Show/switch AI engine (opencode or claude)\n" "โ€ข `model` โ€” Show/switch AI model\n" "โ€ข `provider` โ€” Show/switch provider (opencode only)\n" "โ€ข `usage` โ€” Show LLM usage stats and costs\n" "\n" "*Config:*\n" "โ€ข `config` โ€” View config summary\n" "โ€ข `config show` โ€” View full config.json\n" "โ€ข `config set ` โ€” Edit a config value\n" "\n" "*Scheduler:*\n" "โ€ข `cron list` โ€” List scheduled jobs\n" "โ€ข `cron remove ` โ€” Remove a scheduled job\n" "โ€ข `subagents` โ€” List active background tasks\n" "\n" "*MCP Servers:*\n" "โ€ข `mcp list` โ€” List configured MCP servers\n" "โ€ข `mcp add [args]` โ€” Add a server\n" "โ€ข `mcp remove ` โ€” Remove a server\n" "\n" "*Skills:*\n" "โ€ข `skill list` โ€” List loaded skills\n" "โ€ข `skill create ` โ€” Create a new skill\n" "โ€ข `skill show ` โ€” View a skill\n" "โ€ข `skill remove ` โ€” Remove a skill\n" "\n" "*AI Chat:*\n" "โ€ข Send any other message and the AI will respond\n" "โ€ข Each thread maintains its own conversation\n" "โ€ข DMs work too โ€” just message me directly\n" ) def _format_sessions() -> str: """Format session info.""" global _runtime if _runtime: count = _runtime.get_status()["active_sessions"] cleaned = _runtime.cleanup_sessions() return ( f"๐Ÿงต *Active Sessions:* {count}\n" f"๐Ÿงน *Cleaned up:* {cleaned} stale sessions" ) return "โš ๏ธ Runtime not initialized." def _format_subagents() -> str: """Format active subagent tasks.""" global _subagent_mgr if not _subagent_mgr: return "โš ๏ธ Subagent manager not initialized." active = _subagent_mgr.list_active() if not active: return "๐Ÿ“‹ No active subagents." lines = ["๐Ÿค– *Active Subagents:*\n"] for task in active: lines.append(f"โ€ข `{task.id}` โ€” {task.status} โ€” {task.task[:60]}") return "\n".join(lines) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): global _runtime, _memory, _skills, _scheduler, _subagent_mgr, _heartbeat global _hook_mgr, _webhook_receiver global _adapters, _use_claude, _cli_args parser = argparse.ArgumentParser( description="Aetheel โ€” AI-Powered Personal Assistant", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: python main.py Start with config-driven adapters python main.py --claude Override: use Claude Code runtime python main.py --test Echo-only handler (no AI) python main.py --model anthropic/claude-sonnet-4-20250514 python main.py --log DEBUG Debug logging All adapters and features are controlled via ~/.aetheel/config.json. CLI flags are optional overrides. """, ) parser.add_argument("--test", action="store_true", help="Use echo handler for testing") parser.add_argument("--claude", action="store_true", help="Override: use Claude Code runtime") parser.add_argument("--cli", action="store_true", help="Override: force CLI mode (OpenCode)") parser.add_argument("--sdk", action="store_true", help="Override: force SDK mode (OpenCode)") parser.add_argument("--telegram", action="store_true", help="Override: enable Telegram adapter") parser.add_argument("--discord", action="store_true", help="Override: enable Discord adapter") parser.add_argument("--webchat", action="store_true", help="Override: enable WebChat adapter") parser.add_argument("--model", default=None, help="Override: model to use") parser.add_argument( "--log", default=os.environ.get("LOG_LEVEL", "INFO"), help="Log level (DEBUG, INFO, WARNING, ERROR)", ) args = parser.parse_args() _cli_args = args # ------------------------------------------------------------------- # 0. Load Configuration (config.json + .env + CLI overrides) # ------------------------------------------------------------------- save_default_config() # Create ~/.aetheel/config.json if missing cfg = load_config() # CLI flags override config (flags are optional overrides, config is primary) if args.claude: cfg.runtime.engine = "claude" _use_claude = cfg.runtime.engine == "claude" log_level = args.log if args.log != "INFO" else cfg.log_level if args.model: cfg.runtime.model = args.model cfg.claude.model = args.model if args.cli: cfg.runtime.mode = "cli" elif args.sdk: cfg.runtime.mode = "sdk" # CLI flags can enable adapters (but config is the primary source) if args.telegram: cfg.telegram.enabled = True if args.discord: cfg.discord.enabled = True if args.webchat: cfg.webchat.enabled = True # Auto-enable adapters when tokens are present (even if not explicitly enabled) if cfg.telegram.bot_token and not cfg.telegram.enabled: cfg.telegram.enabled = True logger.debug("Telegram auto-enabled: token present") if cfg.discord.bot_token and not cfg.discord.enabled: cfg.discord.enabled = True logger.debug("Discord auto-enabled: token present") # Configure logging logging.basicConfig( level=getattr(logging, log_level.upper(), logging.INFO), format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger.info(f"Config: {CONFIG_PATH}") # ------------------------------------------------------------------- # 1. Initialize Memory System # ------------------------------------------------------------------- workspace_dir = os.path.expanduser(cfg.memory.workspace) db_path = os.path.expanduser(cfg.memory.db_path) try: mem_config = MemoryConfig( workspace_dir=workspace_dir, db_path=db_path, ) _memory = MemoryManager(mem_config) logger.info(f"Memory system initialized: workspace={workspace_dir}") stats = asyncio.run(_memory.sync()) logger.info( f"Memory sync: {stats.get('files_indexed', 0)} files indexed, " f"{stats.get('chunks_created', 0)} chunks" ) except Exception as e: logger.warning(f"Memory system init failed (continuing without): {e}") _memory = None # ------------------------------------------------------------------- # 2. Initialize Skills System # ------------------------------------------------------------------- try: _skills = SkillsManager(workspace_dir) loaded = _skills.load_all() logger.info(f"Skills system initialized: {len(loaded)} skill(s)") except Exception as e: logger.warning(f"Skills system init failed (continuing without): {e}") _skills = None # ------------------------------------------------------------------- # 2ยฝ. Write MCP config (before runtime sees it) # ------------------------------------------------------------------- write_mcp_config(cfg.mcp, cfg.memory.workspace, _use_claude) # ------------------------------------------------------------------- # 3. Initialize AI Runtime # ------------------------------------------------------------------- runtime_label = "echo (test mode)" if not args.test: if _use_claude: claude_config = ClaudeCodeConfig( model=cfg.claude.model, timeout_seconds=cfg.claude.timeout_seconds, max_turns=cfg.claude.max_turns, no_tools=cfg.claude.no_tools, allowed_tools=cfg.claude.allowed_tools, ) _runtime = ClaudeCodeRuntime(claude_config) runtime_label = f"claude-code, model={claude_config.model or 'default'}" else: oc_config = OpenCodeConfig( mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI, server_url=cfg.runtime.server_url, timeout_seconds=cfg.runtime.timeout_seconds, model=cfg.runtime.model, provider=cfg.runtime.provider, workspace_dir=cfg.runtime.workspace, format=cfg.runtime.format, ) _runtime = OpenCodeRuntime(oc_config) runtime_label = ( f"opencode/{oc_config.mode.value}, " f"model={oc_config.model or 'default'}" ) # ------------------------------------------------------------------- # 4. Initialize Scheduler # ------------------------------------------------------------------- try: _scheduler = Scheduler(callback=_on_scheduled_job) _scheduler.start() logger.info("Scheduler initialized") except Exception as e: logger.warning(f"Scheduler init failed (continuing without): {e}") _scheduler = None # ------------------------------------------------------------------- # 4b. Initialize Heartbeat System # ------------------------------------------------------------------- heartbeat_count = 0 if _scheduler: try: _heartbeat = HeartbeatRunner( scheduler=_scheduler, ai_handler_fn=ai_handler, send_fn=_send_to_channel, config=cfg.heartbeat, workspace_dir=workspace_dir, ) heartbeat_count = _heartbeat.start() logger.info(f"Heartbeat initialized: {heartbeat_count} task(s)") except Exception as e: logger.warning(f"Heartbeat init failed (continuing without): {e}") _heartbeat = None # ------------------------------------------------------------------- # 5. Initialize Subagent Manager # ------------------------------------------------------------------- if _runtime: try: _subagent_mgr = SubagentManager( runtime_factory=_make_runtime, send_fn=_send_to_channel, ) logger.info("Subagent manager initialized") except Exception as e: logger.warning(f"Subagent manager init failed: {e}") _subagent_mgr = None # ------------------------------------------------------------------- # 5b. Initialize Hook System # ------------------------------------------------------------------- hook_count = 0 if cfg.hooks.enabled: try: _hook_mgr = HookManager(workspace_dir=workspace_dir) hooks_found = _hook_mgr.discover() hook_count = len(hooks_found) logger.info(f"Hook system initialized: {hook_count} hook(s)") except Exception as e: logger.warning(f"Hook system init failed (continuing without): {e}") _hook_mgr = None # ------------------------------------------------------------------- # 5c. Initialize Webhook Receiver # ------------------------------------------------------------------- if cfg.webhooks.enabled: try: from webhooks.receiver import WebhookReceiver, WebhookConfig as WHConfig wh_config = WHConfig( enabled=cfg.webhooks.enabled, port=cfg.webhooks.port, host=cfg.webhooks.host, token=cfg.webhooks.token, ) _webhook_receiver = WebhookReceiver( ai_handler_fn=ai_handler, send_fn=_send_to_channel, config=wh_config, ) _webhook_receiver.start_async() logger.info( f"Webhook receiver started at " f"http://{cfg.webhooks.host}:{cfg.webhooks.port}/hooks/" ) except Exception as e: logger.warning(f"Webhook receiver init failed (continuing without): {e}") _webhook_receiver = None # ------------------------------------------------------------------- # 6. Initialize Channel Adapters # ------------------------------------------------------------------- # Choose the message handler handler = echo_handler if args.test else ai_handler # Slack adapter (enabled when tokens are present and not disabled) if cfg.slack.enabled and cfg.slack.bot_token and cfg.slack.app_token: try: slack = SlackAdapter( bot_token=cfg.slack.bot_token, app_token=cfg.slack.app_token, log_level=log_level, ) slack.on_message(handler) _adapters["slack"] = slack logger.info("Slack adapter registered") except Exception as e: logger.error(f"Slack adapter failed to initialize: {e}") elif cfg.slack.enabled: logger.warning("Slack enabled but tokens not set โ€” Slack adapter disabled") # Telegram adapter (enabled via config or token auto-detection) if cfg.telegram.enabled: if cfg.telegram.bot_token: try: from adapters.telegram_adapter import TelegramAdapter telegram = TelegramAdapter(bot_token=cfg.telegram.bot_token) telegram.on_message(handler) _adapters["telegram"] = telegram logger.info("Telegram adapter registered") except Exception as e: logger.error(f"Telegram adapter failed to initialize: {e}") else: logger.warning( "Telegram enabled but TELEGRAM_BOT_TOKEN not set. " "Get a token from @BotFather on Telegram." ) # Discord adapter (enabled via config or token auto-detection) if cfg.discord.enabled: if cfg.discord.bot_token: try: from adapters.discord_adapter import DiscordAdapter discord_adapter = DiscordAdapter( bot_token=cfg.discord.bot_token, listen_channels=cfg.discord.listen_channels or None, ) discord_adapter.on_message(handler) _adapters["discord"] = discord_adapter logger.info("Discord adapter registered") except Exception as e: logger.error(f"Discord adapter failed to initialize: {e}") else: logger.warning( "Discord enabled but DISCORD_BOT_TOKEN not set. " "Get a token from https://discord.com/developers/applications" ) # WebChat adapter (enabled via config) if cfg.webchat.enabled: try: from adapters.webchat_adapter import WebChatAdapter webchat = WebChatAdapter( host=cfg.webchat.host, port=cfg.webchat.port, ) webchat.on_message(handler) _adapters["webchat"] = webchat logger.info(f"WebChat adapter registered at http://{cfg.webchat.host}:{cfg.webchat.port}") except Exception as e: logger.error(f"WebChat adapter failed to initialize: {e}") if not _adapters: print("โŒ No channel adapters initialized!") print(" Configure adapters in ~/.aetheel/config.json") print(" Set tokens in .env (SLACK_BOT_TOKEN, DISCORD_BOT_TOKEN, etc.)") print(" Or enable webchat: {\"webchat\": {\"enabled\": true}}") sys.exit(1) # Start file watching for automatic memory re-indexing if _memory: _memory.start_watching() # ------------------------------------------------------------------- # 7. Start Adapters # ------------------------------------------------------------------- logger.info("=" * 60) logger.info(" Aetheel Starting") logger.info("=" * 60) logger.info(f" Config: {CONFIG_PATH}") logger.info(f" Runtime: {runtime_label}") logger.info(f" Channels: {', '.join(_adapters.keys())}") logger.info(f" Skills: {len(_skills.skills) if _skills else 0}") logger.info(f" Scheduler: {'โœ…' if _scheduler else 'โŒ'}") logger.info(f" Heartbeat: {'โœ… ' + str(heartbeat_count) + ' tasks' if _heartbeat else 'โŒ'}") logger.info(f" Subagents: {'โœ…' if _subagent_mgr else 'โŒ'}") logger.info(f" Hooks: {'โœ… ' + str(hook_count) + ' hooks' if _hook_mgr else 'โŒ'}") logger.info(f" Webhooks: {'โœ… port ' + str(cfg.webhooks.port) if _webhook_receiver else 'โŒ'}") logger.info("=" * 60) try: # Fire gateway:startup hook if _hook_mgr: _hook_mgr.trigger(HookEvent(type="gateway", action="startup")) if len(_adapters) == 1: # Single adapter โ€” start it blocking adapter = next(iter(_adapters.values())) adapter.start() else: # Multiple adapters โ€” start all but last async, last blocking adapter_list = list(_adapters.values()) for adapter in adapter_list[:-1]: adapter.start_async() adapter_list[-1].start() # blocking except KeyboardInterrupt: logger.info("Shutting down...") finally: # Fire gateway:shutdown hook if _hook_mgr: _hook_mgr.trigger(HookEvent(type="gateway", action="shutdown")) # Cleanup if _webhook_receiver: _webhook_receiver.stop() for adapter in _adapters.values(): try: adapter.stop() except Exception: pass if _scheduler: _scheduler.stop() if _memory: _memory.close() logger.info("Aetheel stopped. Goodbye! ๐Ÿ‘‹") if __name__ == "__main__": main()