550 lines
17 KiB
Python
550 lines
17 KiB
Python
#!/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|<minutes>|<message>]
|
|
|
|
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()
|