#!/usr/bin/env python3 """ Aetheel Slack Service — Main Entry Point ========================================= Starts the Slack adapter in Socket Mode, connected to the OpenCode AI runtime. Usage: python main.py # Run with OpenCode AI handler python main.py --test # Run with echo handler for testing python main.py --cli # Force CLI mode (subprocess) python main.py --sdk # Force SDK mode (opencode serve) Environment: SLACK_BOT_TOKEN — Slack bot token (xoxb-...) SLACK_APP_TOKEN — Slack app-level token (xapp-...) OPENCODE_MODE — "cli" or "sdk" (default: cli) OPENCODE_MODEL — Model to use (e.g., anthropic/claude-sonnet-4-20250514) OPENCODE_SERVER_URL — SDK server URL (default: http://localhost:4096) OPENCODE_TIMEOUT — CLI timeout in seconds (default: 120) LOG_LEVEL — Optional, default: INFO """ 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 load_dotenv() from adapters.slack_adapter import SlackAdapter, SlackMessage from agent.claude_runtime import ClaudeCodeConfig, ClaudeCodeRuntime from agent.opencode_runtime import ( AgentResponse, OpenCodeConfig, OpenCodeRuntime, RuntimeMode, build_aetheel_system_prompt, ) from memory import MemoryManager from memory.types import MemoryConfig logger = logging.getLogger("aetheel") # Type alias for either runtime AnyRuntime = OpenCodeRuntime | ClaudeCodeRuntime # Global runtime instance (initialized in main) _runtime: AnyRuntime | None = None _memory: MemoryManager | None = None _slack_adapter: SlackAdapter | None = None # Regex for parsing action tags from AI responses _ACTION_RE = re.compile(r"\[ACTION:remind\|(\d+)\|(.+?)\]", re.DOTALL) # --------------------------------------------------------------------------- # Message Handlers # --------------------------------------------------------------------------- def echo_handler(msg: SlackMessage) -> str: """ Simple echo handler for testing. Returns a formatted response with message details. """ 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"💬 *Type:* {'DM' if msg.is_dm else 'Mention' if msg.is_mention else 'Channel'}", f"🧵 *Thread:* `{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_memory_context(msg: SlackMessage) -> str: """ Build memory context to inject into the system prompt. Reads identity files (SOUL.md, USER.md) and searches long-term memory for relevant context based on the user's message. """ global _memory if _memory is None: return "" sections: list[str] = [] # ── Identity: SOUL.md ── 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: # Skip if it's just the identity files themselves (already included) 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}") return "\n\n---\n\n".join(sections) def ai_handler(msg: SlackMessage) -> str: """ AI-powered handler using OpenCode runtime. This is the heart of Aetheel — it routes incoming Slack messages through the OpenCode agent runtime, which handles: - Memory context injection (SOUL.md, USER.md, MEMORY.md) - Session management (per-thread) - Model selection - System prompt injection - Response generation - Conversation logging Flow: Slack message → memory context → ai_handler → OpenCodeRuntime.chat() → AI response → session log """ global _runtime, _memory if _runtime is None: return "⚠️ AI runtime not initialized. Please restart the service." text_lower = msg.text.lower().strip() # Built-in commands (bypass AI) if text_lower in ("status", "/status", "ping"): return _format_status() if text_lower in ("help", "/help"): return _format_help() if text_lower == "time": return f"🕐 Server time: *{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*" if text_lower in ("sessions", "/sessions"): return _format_sessions() # Build memory context from identity files + search memory_context = _build_memory_context(msg) # Route to AI via OpenCode system_prompt = build_aetheel_system_prompt( user_name=msg.user_name, channel_name=msg.channel_name, is_dm=msg.is_dm, extra_context=memory_context, ) response = _runtime.chat( message=msg.text, conversation_id=msg.conversation_id, system_prompt=system_prompt, ) if not response.ok: error_msg = response.error or "Unknown error" logger.error(f"AI error: {error_msg}") # Provide a helpful error message if "not found" in error_msg.lower() or "not installed" in error_msg.lower(): return ( "⚠️ OpenCode CLI is not available.\n" "Install it with: `curl -fsSL https://opencode.ai/install | bash`\n" "See `docs/opencode-setup.md` for details." ) 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]}" # Log response stats logger.info( f"🤖 AI response: {len(response.text)} chars, " f"{response.duration_ms}ms" ) # Parse and execute action tags (e.g., reminders) 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 "slack" _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: SlackMessage) -> str: """ Parse and execute action tags from the AI response. Currently supports: [ACTION:remind||] Returns the response text with action tags stripped out. """ cleaned = text # Find all reminder action tags for match in _ACTION_RE.finditer(text): minutes_str, reminder_msg = match.group(1), match.group(2) try: minutes = int(minutes_str) _schedule_reminder( delay_minutes=minutes, message=reminder_msg.strip(), channel_id=msg.channel_id, thread_ts=msg.thread_ts if hasattr(msg, "thread_ts") else None, user_name=msg.user_name, ) logger.info( f"⏰ Reminder scheduled: '{reminder_msg.strip()[:50]}' " f"in {minutes} min for #{msg.channel_name}" ) except Exception as e: logger.warning(f"Failed to schedule reminder: {e}") # Strip the action tag from the visible response cleaned = cleaned.replace(match.group(0), "").strip() return cleaned def _schedule_reminder( *, delay_minutes: int, message: str, channel_id: str, thread_ts: str | None = None, user_name: str | None = None, ) -> None: """ Schedule a Slack message to be sent after a delay. Uses a background thread with a timer. """ global _slack_adapter delay_seconds = delay_minutes * 60 def _send_reminder(): try: if _slack_adapter and _slack_adapter._app: mention = f"@{user_name}" if user_name else "" reminder_text = f"⏰ *Reminder* {mention}: {message}" kwargs = { "channel": channel_id, "text": reminder_text, } if thread_ts: kwargs["thread_ts"] = thread_ts _slack_adapter._app.client.chat_postMessage(**kwargs) logger.info(f"⏰ Reminder sent: '{message[:50]}'") else: logger.warning("Cannot send reminder: Slack adapter not available") except Exception as e: logger.error(f"Failed to send reminder: {e}") timer = threading.Timer(delay_seconds, _send_reminder) timer.daemon = True timer.start() # --------------------------------------------------------------------------- # Formatting Helpers # --------------------------------------------------------------------------- def _format_status() -> str: """Format the /status response with runtime info.""" global _runtime lines = [ "🟢 *Aetheel is online*", "", ] if _runtime: status = _runtime.get_status() lines.extend([ f"• *Mode:* {status['mode']}", f"• *Model:* {status['model']}", f"• *Provider:* {status['provider']}", f"• *Active Sessions:* {status['active_sessions']}", f"• *OpenCode Available:* {'✅' if status['opencode_available'] else '❌'}", ]) if "sdk_connected" in status: lines.append( f"• *SDK Connected:* {'✅' if status['sdk_connected'] else '❌'}" ) else: lines.append("• Runtime: not initialized") 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" "*Built-in Commands:*\n" "• `status` — Check bot and AI runtime status\n" "• `help` — Show this help message\n" "• `time` — Current server time\n" "• `sessions` — Active session count\n" "\n" "*AI Chat:*\n" "• Send any message and the AI will respond\n" "• Each thread maintains its own conversation\n" "• DMs work too — just message me directly\n" "\n" "_Powered by OpenCode — https://opencode.ai_" ) 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." # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="Aetheel Slack Service — AI-Powered via OpenCode or Claude Code", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python main.py Start with AI handler (OpenCode) python main.py --claude Start with Claude Code runtime python main.py --test Start with echo-only handler python main.py --cli Force CLI mode (subprocess, OpenCode) python main.py --sdk Force SDK mode (opencode serve) python main.py --model anthropic/claude-sonnet-4-20250514 python main.py --log DEBUG Start with debug logging """, ) parser.add_argument( "--test", action="store_true", help="Use simple echo handler for testing", ) parser.add_argument( "--claude", action="store_true", help="Use Claude Code runtime instead of OpenCode", ) parser.add_argument( "--cli", action="store_true", help="Force CLI mode (opencode run subprocess)", ) parser.add_argument( "--sdk", action="store_true", help="Force SDK mode (opencode serve API)", ) parser.add_argument( "--model", default=None, help="Model to use (e.g., anthropic/claude-sonnet-4-20250514)", ) parser.add_argument( "--log", default=os.environ.get("LOG_LEVEL", "INFO"), help="Log level (DEBUG, INFO, WARNING, ERROR)", ) args = parser.parse_args() # Configure logging logging.basicConfig( level=getattr(logging, args.log.upper(), logging.INFO), format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) # Validate Slack tokens are present if not os.environ.get("SLACK_BOT_TOKEN"): print("❌ SLACK_BOT_TOKEN is not set!") print(" Copy .env.example to .env and add your tokens.") print(" See docs/slack-setup.md for instructions.") sys.exit(1) if not os.environ.get("SLACK_APP_TOKEN"): print("❌ SLACK_APP_TOKEN is not set!") print(" Copy .env.example to .env and add your tokens.") print(" See docs/slack-setup.md for instructions.") sys.exit(1) # Initialize memory system global _runtime, _memory workspace_dir = os.environ.get( "AETHEEL_WORKSPACE", os.path.expanduser("~/.aetheel/workspace") ) db_path = os.environ.get( "AETHEEL_MEMORY_DB", os.path.expanduser("~/.aetheel/memory.db") ) 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}" ) # Initial sync (indexes identity files on first run) 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 # Initialize AI runtime (unless in test mode) if not args.test: if args.claude: # Claude Code runtime claude_config = ClaudeCodeConfig.from_env() if args.model: claude_config.model = args.model _runtime = ClaudeCodeRuntime(claude_config) runtime_label = f"claude-code, model={claude_config.model or 'default'}" else: # OpenCode runtime (default) config = OpenCodeConfig.from_env() # CLI flag overrides if args.cli: config.mode = RuntimeMode.CLI elif args.sdk: config.mode = RuntimeMode.SDK if args.model: config.model = args.model _runtime = OpenCodeRuntime(config) runtime_label = ( f"opencode/{config.mode.value}, " f"model={config.model or 'default'}" ) # Create Slack adapter global _slack_adapter adapter = SlackAdapter(log_level=args.log) _slack_adapter = adapter # Register handler if args.test: adapter.on_message(echo_handler) logger.info("Using echo handler (test mode)") else: adapter.on_message(ai_handler) logger.info(f"Using AI handler ({runtime_label})") # Start file watching for automatic memory re-indexing if _memory: _memory.start_watching() # Start (blocking) try: adapter.start() except KeyboardInterrupt: if _memory: _memory.close() adapter.stop() except Exception as e: if _memory: _memory.close() logger.error(f"Fatal error: {e}", exc_info=True) sys.exit(1) if __name__ == "__main__": main()