latest updates
This commit is contained in:
672
main.py
672
main.py
@@ -1,23 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Aetheel Slack Service — Main Entry Point
|
||||
=========================================
|
||||
Starts the Slack adapter in Socket Mode, connected to the OpenCode AI runtime.
|
||||
Aetheel — Main Entry Point
|
||||
============================
|
||||
Starts the AI assistant with multi-channel adapters, memory, skills,
|
||||
scheduled tasks, and subagent support.
|
||||
|
||||
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
|
||||
python main.py Start with Slack + AI handler
|
||||
python main.py --telegram Also enable Telegram 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
|
||||
@@ -34,7 +28,8 @@ from dotenv import load_dotenv
|
||||
# Load .env file
|
||||
load_dotenv()
|
||||
|
||||
from adapters.slack_adapter import SlackAdapter, SlackMessage
|
||||
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,
|
||||
@@ -43,21 +38,34 @@ from agent.opencode_runtime import (
|
||||
RuntimeMode,
|
||||
build_aetheel_system_prompt,
|
||||
)
|
||||
from agent.subagent import SubagentManager
|
||||
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 runtime instance (initialized in main)
|
||||
# Global instances (initialized in main)
|
||||
_runtime: AnyRuntime | None = None
|
||||
_memory: MemoryManager | None = None
|
||||
_slack_adapter: SlackAdapter | None = None
|
||||
_skills: SkillsManager | None = None
|
||||
_scheduler: Scheduler | None = None
|
||||
_subagent_mgr: SubagentManager | None = None
|
||||
_adapters: dict[str, BaseAdapter] = {} # source_name -> adapter
|
||||
|
||||
# Runtime config (stored for subagent factory)
|
||||
_use_claude: bool = False
|
||||
_cli_args: argparse.Namespace | None = None
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,19 +73,16 @@ _ACTION_RE = re.compile(r"\[ACTION:remind\|(\d+)\|(.+?)\]", re.DOTALL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def echo_handler(msg: SlackMessage) -> str:
|
||||
"""
|
||||
Simple echo handler for testing.
|
||||
Returns a formatted response with message details.
|
||||
"""
|
||||
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"💬 *Type:* {'DM' if msg.is_dm else 'Mention' if msg.is_mention else 'Channel'}",
|
||||
f"🧵 *Thread:* `{msg.conversation_id[:15]}...`",
|
||||
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._",
|
||||
@@ -85,75 +90,80 @@ def echo_handler(msg: SlackMessage) -> str:
|
||||
return "\n".join(response_lines)
|
||||
|
||||
|
||||
def _build_memory_context(msg: SlackMessage) -> str:
|
||||
def _build_context(msg: IncomingMessage) -> str:
|
||||
"""
|
||||
Build memory context to inject into the system prompt.
|
||||
Build full 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.
|
||||
Combines:
|
||||
- Identity files (SOUL.md, USER.md, MEMORY.md)
|
||||
- Relevant memory search results
|
||||
- Relevant skills for this message
|
||||
- Available skills summary
|
||||
"""
|
||||
global _memory
|
||||
if _memory is None:
|
||||
return ""
|
||||
global _memory, _skills
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
# ── Identity: SOUL.md ──
|
||||
soul = _memory.read_soul()
|
||||
if soul:
|
||||
sections.append(f"# Your Identity (SOUL.md)\n\n{soul}")
|
||||
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}")
|
||||
# ── 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}")
|
||||
# ── 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}")
|
||||
# ── 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: SlackMessage) -> str:
|
||||
def ai_handler(msg: IncomingMessage) -> 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
|
||||
AI-powered handler — the heart of Aetheel.
|
||||
|
||||
Flow:
|
||||
Slack message → memory context → ai_handler → OpenCodeRuntime.chat() → AI response → session log
|
||||
Message → context (memory + skills) → system prompt → runtime.chat()
|
||||
→ action tags → session log → response
|
||||
"""
|
||||
global _runtime, _memory
|
||||
global _runtime, _memory, _scheduler
|
||||
|
||||
if _runtime is None:
|
||||
return "⚠️ AI runtime not initialized. Please restart the service."
|
||||
@@ -173,15 +183,19 @@ def ai_handler(msg: SlackMessage) -> str:
|
||||
if text_lower in ("sessions", "/sessions"):
|
||||
return _format_sessions()
|
||||
|
||||
# Build memory context from identity files + search
|
||||
memory_context = _build_memory_context(msg)
|
||||
# Cron management commands
|
||||
if text_lower.startswith("/cron"):
|
||||
return _handle_cron_command(text_lower)
|
||||
|
||||
# Route to AI via OpenCode
|
||||
# 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=memory_context,
|
||||
extra_context=context,
|
||||
)
|
||||
|
||||
response = _runtime.chat(
|
||||
@@ -194,12 +208,10 @@ def ai_handler(msg: SlackMessage) -> str:
|
||||
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."
|
||||
"⚠️ AI CLI is not available.\n"
|
||||
"Check the runtime installation docs."
|
||||
)
|
||||
if "timeout" in error_msg.lower():
|
||||
return (
|
||||
@@ -208,19 +220,18 @@ def ai_handler(msg: SlackMessage) -> str:
|
||||
)
|
||||
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)
|
||||
# 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 "slack"
|
||||
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}",
|
||||
@@ -237,81 +248,216 @@ def ai_handler(msg: SlackMessage) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _process_action_tags(text: str, msg: SlackMessage) -> str:
|
||||
def _process_action_tags(text: str, msg: IncomingMessage) -> 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.
|
||||
Supports:
|
||||
[ACTION:remind|<minutes>|<message>] → one-shot reminder
|
||||
[ACTION:cron|<cron_expr>|<prompt>] → recurring cron job
|
||||
[ACTION:spawn|<task description>] → background subagent
|
||||
"""
|
||||
cleaned = text
|
||||
|
||||
# Find all reminder action tags
|
||||
# ── 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)
|
||||
_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,
|
||||
)
|
||||
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.channel_name}"
|
||||
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()
|
||||
|
||||
# Strip the action tag from the visible response
|
||||
# ── 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
|
||||
|
||||
|
||||
def _schedule_reminder(
|
||||
*,
|
||||
delay_minutes: int,
|
||||
message: str,
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 <id>`"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler Callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
thread_ts: str | None = None,
|
||||
user_name: str | None = None,
|
||||
text: str,
|
||||
thread_id: str | None,
|
||||
channel_type: str,
|
||||
) -> None:
|
||||
"""
|
||||
Schedule a Slack message to be sent after a delay.
|
||||
Uses a background thread with a timer.
|
||||
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.
|
||||
"""
|
||||
global _slack_adapter
|
||||
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"
|
||||
)
|
||||
|
||||
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}"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime Factory (for subagents)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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}")
|
||||
def _make_runtime() -> AnyRuntime:
|
||||
"""Create a fresh runtime instance (used by subagent manager)."""
|
||||
global _use_claude, _cli_args
|
||||
|
||||
if _use_claude:
|
||||
config = ClaudeCodeConfig.from_env()
|
||||
if _cli_args and _cli_args.model:
|
||||
config.model = _cli_args.model
|
||||
return ClaudeCodeRuntime(config)
|
||||
else:
|
||||
config = OpenCodeConfig.from_env()
|
||||
if _cli_args and _cli_args.model:
|
||||
config.model = _cli_args.model
|
||||
return OpenCodeRuntime(config)
|
||||
|
||||
timer = threading.Timer(delay_seconds, _send_reminder)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting Helpers
|
||||
@@ -320,7 +466,7 @@ def _schedule_reminder(
|
||||
|
||||
def _format_status() -> str:
|
||||
"""Format the /status response with runtime info."""
|
||||
global _runtime
|
||||
global _runtime, _scheduler, _skills, _subagent_mgr
|
||||
|
||||
lines = [
|
||||
"🟢 *Aetheel is online*",
|
||||
@@ -334,14 +480,27 @@ def _format_status() -> str:
|
||||
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")
|
||||
|
||||
# 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([
|
||||
"",
|
||||
@@ -361,13 +520,18 @@ def _format_help() -> str:
|
||||
"• `help` — Show this help message\n"
|
||||
"• `time` — Current server time\n"
|
||||
"• `sessions` — Active session count\n"
|
||||
"• `/cron list` — List scheduled jobs\n"
|
||||
"• `/cron remove <id>` — Remove a scheduled job\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_"
|
||||
"*AI Actions:*\n"
|
||||
"• The AI can schedule reminders\n"
|
||||
"• The AI can set up recurring cron jobs\n"
|
||||
"• The AI can spawn background subagents for long tasks\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -390,51 +554,36 @@ def _format_sessions() -> str:
|
||||
|
||||
|
||||
def main():
|
||||
global _runtime, _memory, _skills, _scheduler, _subagent_mgr
|
||||
global _adapters, _use_claude, _cli_args
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Aetheel Slack Service — AI-Powered via OpenCode or Claude Code",
|
||||
description="Aetheel — AI-Powered Personal Assistant",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
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 Start with Slack + AI handler
|
||||
python main.py --telegram Also enable Telegram adapter
|
||||
python main.py --claude Use Claude Code runtime
|
||||
python main.py --test Echo-only handler
|
||||
python main.py --model anthropic/claude-sonnet-4-20250514
|
||||
python main.py --log DEBUG Start with debug logging
|
||||
python main.py --log DEBUG 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("--test", action="store_true", help="Use echo handler for testing")
|
||||
parser.add_argument("--claude", action="store_true", help="Use Claude Code runtime")
|
||||
parser.add_argument("--cli", action="store_true", help="Force CLI mode (OpenCode)")
|
||||
parser.add_argument("--sdk", action="store_true", help="Force SDK mode (OpenCode)")
|
||||
parser.add_argument("--telegram", action="store_true", help="Enable Telegram adapter")
|
||||
parser.add_argument("--model", default=None, help="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
|
||||
_use_claude = args.claude
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -443,21 +592,9 @@ Examples:
|
||||
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
|
||||
# -------------------------------------------------------------------
|
||||
# 1. Initialize Memory System
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
workspace_dir = os.environ.get(
|
||||
"AETHEEL_WORKSPACE", os.path.expanduser("~/.aetheel/workspace")
|
||||
@@ -472,11 +609,8 @@ Examples:
|
||||
db_path=db_path,
|
||||
)
|
||||
_memory = MemoryManager(mem_config)
|
||||
logger.info(
|
||||
f"Memory system initialized: workspace={workspace_dir}"
|
||||
)
|
||||
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, "
|
||||
@@ -486,63 +620,161 @@ Examples:
|
||||
logger.warning(f"Memory system init failed (continuing without): {e}")
|
||||
_memory = None
|
||||
|
||||
# Initialize AI runtime (unless in test mode)
|
||||
# -------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 3. Initialize AI Runtime
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
runtime_label = "echo (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
|
||||
# -------------------------------------------------------------------
|
||||
# 4. Initialize Scheduler
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Register handler
|
||||
if args.test:
|
||||
adapter.on_message(echo_handler)
|
||||
logger.info("Using echo handler (test mode)")
|
||||
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
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 6. Initialize Channel Adapters
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Choose the message handler
|
||||
handler = echo_handler if args.test else ai_handler
|
||||
|
||||
# Slack adapter (always enabled if tokens are present)
|
||||
slack_token = os.environ.get("SLACK_BOT_TOKEN")
|
||||
slack_app_token = os.environ.get("SLACK_APP_TOKEN")
|
||||
|
||||
if slack_token and slack_app_token:
|
||||
try:
|
||||
slack = SlackAdapter(log_level=args.log)
|
||||
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}")
|
||||
else:
|
||||
adapter.on_message(ai_handler)
|
||||
logger.info(f"Using AI handler ({runtime_label})")
|
||||
logger.warning("Slack tokens not set — Slack adapter disabled")
|
||||
|
||||
# Telegram adapter (enabled with --telegram flag)
|
||||
if args.telegram:
|
||||
telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
if telegram_token:
|
||||
try:
|
||||
from adapters.telegram_adapter import TelegramAdapter
|
||||
|
||||
telegram = TelegramAdapter()
|
||||
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.error(
|
||||
"TELEGRAM_BOT_TOKEN not set — cannot enable Telegram. "
|
||||
"Get a token from @BotFather on Telegram."
|
||||
)
|
||||
|
||||
if not _adapters:
|
||||
print("❌ No channel adapters initialized!")
|
||||
print(" Set SLACK_BOT_TOKEN + SLACK_APP_TOKEN or use --telegram")
|
||||
sys.exit(1)
|
||||
|
||||
# Start file watching for automatic memory re-indexing
|
||||
if _memory:
|
||||
_memory.start_watching()
|
||||
|
||||
# Start (blocking)
|
||||
# -------------------------------------------------------------------
|
||||
# 7. Start Adapters
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(" Aetheel Starting")
|
||||
logger.info("=" * 60)
|
||||
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" Subagents: {'✅' if _subagent_mgr else '❌'}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
adapter.start()
|
||||
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:
|
||||
# Cleanup
|
||||
for adapter in _adapters.values():
|
||||
try:
|
||||
adapter.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if _scheduler:
|
||||
_scheduler.stop()
|
||||
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)
|
||||
logger.info("Aetheel stopped. Goodbye! 👋")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user