Major changes: - Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required - Runtime engine field: runtime.engine selects opencode/claude from config - Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd) - Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json - Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command - Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification - Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help - /config set for editing config.json from chat with dotted key notation - Security audit saved to docs/security-audit.md - Full command reference in docs/commands.md - Future changes doc with NanoClaw agent teams analysis - Logo added to README and WebChat UI - README fully rewritten with all features documented
1176 lines
42 KiB
Python
1176 lines
42 KiB
Python
"""
|
|
OpenCode Agent Runtime
|
|
======================
|
|
Wraps OpenCode CLI as the AI brain for Aetheel, inspired by OpenClaw's cli-runner.ts.
|
|
|
|
Two modes of operation:
|
|
1. **SDK Mode** (preferred) — Connects to a running `opencode serve` instance
|
|
via the official Python SDK (`opencode-ai`). Persistent sessions, low latency.
|
|
2. **CLI Mode** (fallback) — Spawns `opencode run` as a subprocess for each
|
|
request. No persistent server needed, but higher per-request latency.
|
|
|
|
Architecture (modeled after OpenClaw):
|
|
- OpenClaw's `cli-runner.ts` runs CLI agents as subprocesses with configurable
|
|
backends (claude-cli, codex-cli, etc.) via `runCommandWithTimeout()`.
|
|
- OpenClaw's `cli-backends.ts` defines backend configs with command, args,
|
|
output format, model aliases, session handling, etc.
|
|
- We replicate this pattern for OpenCode, but leverage OpenCode's `serve` mode
|
|
and its Python SDK for a cleaner integration.
|
|
|
|
Session Management:
|
|
- Each Slack thread maps to an OpenCode session (via `conversation_id`).
|
|
- Sessions are created on first message and reused for follow-ups.
|
|
- This mirrors OpenClaw's session isolation strategy.
|
|
|
|
Usage:
|
|
from agent.opencode_runtime import OpenCodeRuntime
|
|
|
|
runtime = OpenCodeRuntime(mode="sdk")
|
|
response = runtime.chat("What is Python?", session_id="slack-thread-123")
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
logger = logging.getLogger("aetheel.agent")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rate Limit Detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_RATE_LIMIT_PATTERNS = [
|
|
"rate limit",
|
|
"rate_limit",
|
|
"too many requests",
|
|
"429",
|
|
"quota exceeded",
|
|
"usage limit",
|
|
"capacity",
|
|
"overloaded",
|
|
"credit balance",
|
|
"billing",
|
|
"exceeded your",
|
|
"max usage",
|
|
]
|
|
|
|
|
|
def _is_rate_limited(text: str) -> bool:
|
|
"""Check if an error message indicates a rate limit or quota issue."""
|
|
lower = text.lower()
|
|
return any(pattern in lower for pattern in _RATE_LIMIT_PATTERNS)
|
|
|
|
|
|
def _resolve_opencode_command(explicit: str | None = None) -> str:
|
|
"""
|
|
Resolve the opencode binary path.
|
|
|
|
Python subprocesses don't source ~/.zshrc or ~/.bashrc, so paths like
|
|
~/.opencode/bin won't be in PATH. This function checks common install
|
|
locations to find the binary automatically.
|
|
|
|
Priority:
|
|
1. Explicit path (from OPENCODE_COMMAND env var)
|
|
2. shutil.which (already in system PATH)
|
|
3. ~/.opencode/bin/opencode (official installer default)
|
|
4. ~/.local/bin/opencode (common Linux/macOS location)
|
|
5. npm global installs (npx-style locations)
|
|
"""
|
|
cmd = explicit or "opencode"
|
|
|
|
# If explicit path is absolute and exists, use it directly
|
|
if os.path.isabs(cmd) and os.path.isfile(cmd):
|
|
return cmd
|
|
|
|
# Try system PATH first
|
|
found = shutil.which(cmd)
|
|
if found:
|
|
return found
|
|
|
|
# Check common install locations
|
|
home = Path.home()
|
|
candidates = [
|
|
home / ".opencode" / "bin" / "opencode", # official installer
|
|
home / ".local" / "bin" / "opencode", # common Linux/macOS
|
|
Path("/usr/local/bin/opencode"), # Homebrew / manual
|
|
Path("/opt/homebrew/bin/opencode"), # Homebrew (Apple Silicon)
|
|
]
|
|
|
|
for candidate in candidates:
|
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
logger.info(f"Auto-discovered opencode at: {candidate}")
|
|
return str(candidate)
|
|
|
|
# Return the original command (will fail at runtime with a clear error)
|
|
return cmd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RuntimeMode(Enum):
|
|
"""How the runtime connects to OpenCode."""
|
|
|
|
SDK = "sdk" # via opencode serve + Python SDK
|
|
CLI = "cli" # via opencode run subprocess
|
|
|
|
|
|
@dataclass
|
|
class OpenCodeConfig:
|
|
"""
|
|
Configuration for the OpenCode runtime.
|
|
Modeled after OpenClaw's CliBackendConfig in cli-backends.ts.
|
|
"""
|
|
|
|
# Connection
|
|
mode: RuntimeMode = RuntimeMode.CLI
|
|
server_url: str = "http://localhost:4096"
|
|
server_password: str | None = None
|
|
server_username: str = "opencode"
|
|
|
|
# CLI settings (for CLI mode, mirroring OpenClaw's DEFAULT_CLAUDE_BACKEND)
|
|
command: str = "opencode"
|
|
timeout_seconds: int = 120
|
|
|
|
# Model
|
|
model: str | None = None # e.g., "anthropic/claude-sonnet-4-20250514"
|
|
provider: str | None = None # e.g., "anthropic"
|
|
|
|
# Agent behavior
|
|
system_prompt: str | None = None
|
|
workspace_dir: str | None = None
|
|
format: str = "json" # output format: "default" (formatted) or "json" (raw events)
|
|
|
|
# Session
|
|
auto_create_sessions: bool = True
|
|
session_ttl_hours: int = 24
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "OpenCodeConfig":
|
|
"""Create config from environment variables."""
|
|
mode_str = os.environ.get("OPENCODE_MODE", "cli").lower()
|
|
mode = RuntimeMode.SDK if mode_str == "sdk" else RuntimeMode.CLI
|
|
|
|
return cls(
|
|
mode=mode,
|
|
server_url=os.environ.get(
|
|
"OPENCODE_SERVER_URL", "http://localhost:4096"
|
|
),
|
|
server_password=os.environ.get("OPENCODE_SERVER_PASSWORD"),
|
|
server_username=os.environ.get("OPENCODE_SERVER_USERNAME", "opencode"),
|
|
command=_resolve_opencode_command(
|
|
os.environ.get("OPENCODE_COMMAND")
|
|
),
|
|
timeout_seconds=int(os.environ.get("OPENCODE_TIMEOUT", "120")),
|
|
model=os.environ.get("OPENCODE_MODEL"),
|
|
provider=os.environ.get("OPENCODE_PROVIDER"),
|
|
system_prompt=os.environ.get("OPENCODE_SYSTEM_PROMPT"),
|
|
workspace_dir=os.environ.get(
|
|
"OPENCODE_WORKSPACE",
|
|
os.environ.get("AETHEEL_WORKSPACE"),
|
|
),
|
|
format=os.environ.get("OPENCODE_FORMAT", "json"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent Response
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class AgentResponse:
|
|
"""Response from the agent runtime."""
|
|
|
|
text: str
|
|
session_id: str | None = None
|
|
model: str | None = None
|
|
provider: str | None = None
|
|
duration_ms: int = 0
|
|
usage: dict | None = None
|
|
error: str | None = None
|
|
rate_limited: bool = False
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.error is None and bool(self.text.strip())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session Store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SessionStore:
|
|
"""
|
|
Maps external IDs (e.g., Slack thread_ts) to OpenCode session IDs.
|
|
Mirrors OpenClaw's session isolation: each channel thread gets its own session.
|
|
|
|
Backed by SQLite for persistence across restarts. Falls back to in-memory
|
|
if the database cannot be opened.
|
|
"""
|
|
|
|
def __init__(self, db_path: str | None = None):
|
|
self._lock = threading.Lock()
|
|
self._db_path = db_path or os.path.join(
|
|
os.path.expanduser("~/.aetheel"), "sessions.db"
|
|
)
|
|
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
|
self._init_db()
|
|
|
|
def _init_db(self) -> None:
|
|
"""Initialize the sessions table."""
|
|
with sqlite3.connect(self._db_path) as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
external_id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
source TEXT NOT NULL DEFAULT '',
|
|
created_at REAL NOT NULL,
|
|
last_used REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
logger.debug(f"Session store initialized: {self._db_path}")
|
|
|
|
def _conn(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(self._db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def get(self, external_id: str) -> str | None:
|
|
"""Get the OpenCode session ID for an external conversation ID."""
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT session_id FROM sessions WHERE external_id = ?",
|
|
(external_id,),
|
|
).fetchone()
|
|
if row:
|
|
conn.execute(
|
|
"UPDATE sessions SET last_used = ? WHERE external_id = ?",
|
|
(time.time(), external_id),
|
|
)
|
|
conn.commit()
|
|
return row["session_id"]
|
|
return None
|
|
|
|
def set(self, external_id: str, session_id: str, source: str = "") -> None:
|
|
"""Map an external ID to an OpenCode session ID."""
|
|
now = time.time()
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO sessions (external_id, session_id, source, created_at, last_used)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(external_id) DO UPDATE SET
|
|
session_id = excluded.session_id,
|
|
last_used = excluded.last_used
|
|
""",
|
|
(external_id, session_id, source, now, now),
|
|
)
|
|
conn.commit()
|
|
|
|
def remove(self, external_id: str) -> None:
|
|
"""Remove a session mapping."""
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM sessions WHERE external_id = ?",
|
|
(external_id,),
|
|
)
|
|
conn.commit()
|
|
|
|
def cleanup(self, ttl_hours: int = 24) -> int:
|
|
"""Remove stale sessions older than ttl_hours. Returns count removed."""
|
|
cutoff = time.time() - (ttl_hours * 3600)
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
cursor = conn.execute(
|
|
"DELETE FROM sessions WHERE last_used < ?",
|
|
(cutoff,),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount
|
|
|
|
def list_all(self) -> list[dict]:
|
|
"""List all active sessions (for diagnostics)."""
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT external_id, session_id, source, created_at, last_used "
|
|
"FROM sessions ORDER BY last_used DESC"
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
with self._lock:
|
|
with self._conn() as conn:
|
|
row = conn.execute("SELECT COUNT(*) as c FROM sessions").fetchone()
|
|
return row["c"] if row else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live Session — IPC Message Streaming
|
|
# (Mirrors nanoclaw's MessageStream + IPC polling pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class LiveSession:
|
|
"""
|
|
A live, long-running agent session that accepts follow-up messages.
|
|
|
|
In CLI mode: holds a running `opencode run` subprocess. Follow-up
|
|
messages are queued and sent as new subprocess invocations that
|
|
--continue the same session.
|
|
|
|
In SDK mode: holds a session ID. Follow-up messages are sent via
|
|
the SDK's session.prompt() to the same session.
|
|
"""
|
|
|
|
conversation_id: str
|
|
session_id: str | None = None
|
|
created_at: float = field(default_factory=time.time)
|
|
last_activity: float = field(default_factory=time.time)
|
|
message_count: int = 0
|
|
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
|
|
def touch(self) -> None:
|
|
"""Update last activity timestamp."""
|
|
self.last_activity = time.time()
|
|
|
|
@property
|
|
def idle_seconds(self) -> float:
|
|
return time.time() - self.last_activity
|
|
|
|
|
|
class LiveSessionManager:
|
|
"""
|
|
Manages live sessions with idle timeout and cleanup.
|
|
|
|
This is the IPC streaming layer — it keeps sessions alive between
|
|
messages so follow-up messages go to the same agent context, mirroring
|
|
nanoclaw's container-based session loop.
|
|
"""
|
|
|
|
def __init__(self, idle_timeout_seconds: int = 1800):
|
|
self._sessions: dict[str, LiveSession] = {}
|
|
self._lock = threading.Lock()
|
|
self._idle_timeout = idle_timeout_seconds
|
|
self._cleanup_thread: threading.Thread | None = None
|
|
self._running = False
|
|
|
|
def start(self) -> None:
|
|
"""Start the background cleanup thread."""
|
|
if self._running:
|
|
return
|
|
self._running = True
|
|
self._cleanup_thread = threading.Thread(
|
|
target=self._cleanup_loop, daemon=True, name="live-session-cleanup"
|
|
)
|
|
self._cleanup_thread.start()
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the cleanup thread."""
|
|
self._running = False
|
|
|
|
def get_or_create(self, conversation_id: str) -> LiveSession:
|
|
"""Get an existing live session or create a new one."""
|
|
with self._lock:
|
|
session = self._sessions.get(conversation_id)
|
|
if session:
|
|
session.touch()
|
|
return session
|
|
session = LiveSession(conversation_id=conversation_id)
|
|
self._sessions[conversation_id] = session
|
|
logger.debug(f"Live session created: {conversation_id}")
|
|
return session
|
|
|
|
def get(self, conversation_id: str) -> LiveSession | None:
|
|
"""Get an existing live session (or None)."""
|
|
with self._lock:
|
|
return self._sessions.get(conversation_id)
|
|
|
|
def remove(self, conversation_id: str) -> None:
|
|
"""Remove a live session."""
|
|
with self._lock:
|
|
self._sessions.pop(conversation_id, None)
|
|
|
|
def list_active(self) -> list[LiveSession]:
|
|
"""List all active live sessions."""
|
|
with self._lock:
|
|
return list(self._sessions.values())
|
|
|
|
def _cleanup_loop(self) -> None:
|
|
"""Periodically remove idle sessions."""
|
|
while self._running:
|
|
time.sleep(60)
|
|
with self._lock:
|
|
stale = [
|
|
cid
|
|
for cid, s in self._sessions.items()
|
|
if s.idle_seconds > self._idle_timeout
|
|
]
|
|
for cid in stale:
|
|
del self._sessions[cid]
|
|
logger.info(f"Live session expired (idle): {cid}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OpenCode Runtime
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class OpenCodeRuntime:
|
|
"""
|
|
OpenCode Agent Runtime — the AI brain for Aetheel.
|
|
|
|
Inspired by OpenClaw's `runCliAgent()` in cli-runner.ts:
|
|
- Resolves the CLI backend config
|
|
- Builds CLI args (model, session, system prompt, etc.)
|
|
- Runs the command with a timeout
|
|
- Parses the JSON or text output
|
|
- Returns structured results
|
|
|
|
We adapt this for OpenCode's two modes:
|
|
- SDK mode: uses the opencode-ai Python SDK to talk to `opencode serve`
|
|
- CLI mode: spawns `opencode run` subprocess (like OpenClaw's approach)
|
|
"""
|
|
|
|
def __init__(self, config: OpenCodeConfig | None = None):
|
|
self._config = config or OpenCodeConfig.from_env()
|
|
self._sessions = SessionStore()
|
|
self._live_sessions = LiveSessionManager(
|
|
idle_timeout_seconds=self._config.session_ttl_hours * 3600
|
|
)
|
|
self._live_sessions.start()
|
|
self._sdk_client = None
|
|
self._sdk_available = False
|
|
|
|
# Validate OpenCode is available
|
|
self._validate_installation()
|
|
|
|
# Try to initialize SDK client if in SDK mode
|
|
if self._config.mode == RuntimeMode.SDK:
|
|
self._init_sdk_client()
|
|
|
|
logger.info(
|
|
f"OpenCode runtime initialized "
|
|
f"(mode={self._config.mode.value}, "
|
|
f"model={self._config.model or 'default'})"
|
|
)
|
|
|
|
# -------------------------------------------------------------------
|
|
# Public API
|
|
# -------------------------------------------------------------------
|
|
|
|
def chat(
|
|
self,
|
|
message: str,
|
|
conversation_id: str | None = None,
|
|
system_prompt: str | None = None,
|
|
) -> AgentResponse:
|
|
"""
|
|
Send a message to the AI agent and get a response.
|
|
|
|
This is the main entry point, used by the Slack adapter's message handler.
|
|
If a live session exists for this conversation_id, the message is sent
|
|
as a follow-up to the existing session (IPC streaming). Otherwise a
|
|
new session is created.
|
|
|
|
Args:
|
|
message: The user's message text
|
|
conversation_id: External conversation ID (e.g., Slack thread_ts)
|
|
for session isolation
|
|
system_prompt: Optional per-request system prompt override
|
|
|
|
Returns:
|
|
AgentResponse with the AI's reply
|
|
"""
|
|
started = time.time()
|
|
|
|
if not message.strip():
|
|
return AgentResponse(
|
|
text="", error="Empty message", duration_ms=0
|
|
)
|
|
|
|
try:
|
|
# Check for an active live session — if one exists, this is a
|
|
# follow-up message that should continue the same agent context
|
|
if conversation_id:
|
|
live = self._live_sessions.get(conversation_id)
|
|
if live and live.session_id:
|
|
logger.info(
|
|
f"Follow-up message to live session "
|
|
f"{conversation_id} (agent session={live.session_id[:8]}...)"
|
|
)
|
|
live.touch()
|
|
live.message_count += 1
|
|
|
|
# Route to the appropriate mode
|
|
if self._config.mode == RuntimeMode.SDK and self._sdk_available:
|
|
result = self._chat_sdk(message, conversation_id, system_prompt)
|
|
else:
|
|
result = self._chat_cli(message, conversation_id, system_prompt)
|
|
|
|
result.duration_ms = int((time.time() - started) * 1000)
|
|
|
|
# Track the live session
|
|
if conversation_id and result.session_id:
|
|
live = self._live_sessions.get_or_create(conversation_id)
|
|
live.session_id = result.session_id
|
|
live.touch()
|
|
live.message_count += 1
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
duration_ms = int((time.time() - started) * 1000)
|
|
logger.error(f"Agent error: {e}", exc_info=True)
|
|
return AgentResponse(
|
|
text="",
|
|
error=str(e),
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
def send_followup(
|
|
self,
|
|
message: str,
|
|
conversation_id: str,
|
|
system_prompt: str | None = None,
|
|
) -> AgentResponse:
|
|
"""
|
|
Send a follow-up message to an active live session.
|
|
|
|
This is the IPC streaming entry point — it pipes a new message into
|
|
an existing agent session, mirroring nanoclaw's MessageStream pattern
|
|
where the host writes IPC files that get consumed by the running agent.
|
|
|
|
If no live session exists, falls back to a regular chat() call which
|
|
will create a new session or resume the persisted one.
|
|
|
|
Args:
|
|
message: The follow-up message text
|
|
conversation_id: The conversation to send to
|
|
system_prompt: Optional system prompt override
|
|
|
|
Returns:
|
|
AgentResponse with the AI's reply
|
|
"""
|
|
live = self._live_sessions.get(conversation_id)
|
|
if not live or not live.session_id:
|
|
logger.debug(
|
|
f"No live session for {conversation_id}, "
|
|
f"falling back to chat()"
|
|
)
|
|
return self.chat(message, conversation_id, system_prompt)
|
|
|
|
logger.info(
|
|
f"IPC follow-up: conversation={conversation_id}, "
|
|
f"session={live.session_id[:8]}..., "
|
|
f"msg_count={live.message_count + 1}"
|
|
)
|
|
live.touch()
|
|
live.message_count += 1
|
|
|
|
# Route through the normal chat — the SessionStore already has the
|
|
# mapping from conversation_id → opencode session_id, so the CLI
|
|
# will use --continue --session, and the SDK will reuse the session.
|
|
return self.chat(message, conversation_id, system_prompt)
|
|
|
|
def close_session(self, conversation_id: str) -> bool:
|
|
"""
|
|
Close a live session explicitly.
|
|
|
|
Mirrors nanoclaw's _close sentinel — signals that the session
|
|
should end and resources should be freed.
|
|
|
|
Returns True if a session was closed.
|
|
"""
|
|
live = self._live_sessions.get(conversation_id)
|
|
if live:
|
|
self._live_sessions.remove(conversation_id)
|
|
logger.info(
|
|
f"Live session closed: {conversation_id} "
|
|
f"(messages={live.message_count}, "
|
|
f"alive={int(live.idle_seconds)}s)"
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def get_status(self) -> dict:
|
|
"""Get the runtime status (for the /status command)."""
|
|
status = {
|
|
"mode": self._config.mode.value,
|
|
"model": self._config.model or "default",
|
|
"provider": self._config.provider or "auto",
|
|
"active_sessions": self._sessions.count,
|
|
"live_sessions": len(self._live_sessions.list_active()),
|
|
"opencode_available": self._is_opencode_available(),
|
|
}
|
|
|
|
if self._config.mode == RuntimeMode.SDK:
|
|
status["server_url"] = self._config.server_url
|
|
status["sdk_connected"] = self._sdk_available
|
|
|
|
return status
|
|
|
|
def cleanup_sessions(self) -> int:
|
|
"""Clean up stale sessions. Returns count removed."""
|
|
return self._sessions.cleanup(self._config.session_ttl_hours)
|
|
|
|
# -------------------------------------------------------------------
|
|
# CLI Mode: Subprocess execution
|
|
# (mirrors OpenClaw's runCliAgent → runCommandWithTimeout pattern)
|
|
# -------------------------------------------------------------------
|
|
|
|
def _chat_cli(
|
|
self,
|
|
message: str,
|
|
conversation_id: str | None = None,
|
|
system_prompt: str | None = None,
|
|
) -> AgentResponse:
|
|
"""
|
|
Run OpenCode in CLI mode via `opencode run`.
|
|
|
|
This mirrors OpenClaw's cli-runner.ts:
|
|
1. Build the CLI args (like buildCliArgs)
|
|
2. Run the command with a timeout
|
|
3. Parse the output (like parseCliJson)
|
|
4. Return structured results
|
|
"""
|
|
# Build CLI args — modeled after OpenClaw's buildCliArgs()
|
|
args = self._build_cli_args(message, conversation_id, system_prompt)
|
|
|
|
logger.info(
|
|
f"CLI exec: {self._config.command} run "
|
|
f"(prompt_chars={len(message)}, "
|
|
f"session={conversation_id or 'new'})"
|
|
)
|
|
|
|
try:
|
|
# Run the command — mirrors OpenClaw's runCommandWithTimeout()
|
|
result = subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=self._config.timeout_seconds,
|
|
cwd=self._config.workspace_dir or os.getcwd(),
|
|
env=self._build_cli_env(),
|
|
)
|
|
|
|
stdout = result.stdout.strip()
|
|
stderr = result.stderr.strip()
|
|
|
|
if result.returncode != 0:
|
|
# Mirror OpenClaw's error classification
|
|
error_text = stderr or stdout or "CLI command failed"
|
|
logger.error(
|
|
f"CLI failed (code={result.returncode}): {error_text[:200]}"
|
|
)
|
|
return AgentResponse(
|
|
text="",
|
|
error=f"OpenCode CLI error: {error_text[:500]}",
|
|
rate_limited=_is_rate_limited(error_text),
|
|
)
|
|
|
|
# Parse the output — mirrors OpenClaw's parseCliJson/parseCliJsonl
|
|
response_text = self._parse_cli_output(stdout)
|
|
|
|
if not response_text:
|
|
response_text = stdout # fallback to raw output
|
|
|
|
# Extract session ID if returned
|
|
session_id = self._extract_session_id(stdout)
|
|
if session_id and conversation_id:
|
|
self._sessions.set(conversation_id, session_id)
|
|
|
|
return AgentResponse(
|
|
text=response_text,
|
|
session_id=session_id,
|
|
model=self._config.model,
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error(
|
|
f"CLI timeout after {self._config.timeout_seconds}s"
|
|
)
|
|
return AgentResponse(
|
|
text="",
|
|
error=f"Request timed out after {self._config.timeout_seconds}s",
|
|
)
|
|
|
|
def _build_cli_args(
|
|
self,
|
|
message: str,
|
|
conversation_id: str | None = None,
|
|
system_prompt: str | None = None,
|
|
) -> list[str]:
|
|
"""
|
|
Build CLI arguments for `opencode run`.
|
|
|
|
Modeled after OpenClaw's buildCliArgs() in cli-runner/helpers.ts:
|
|
- base args (command + run)
|
|
- model arg (--model)
|
|
- session arg (--session / --continue)
|
|
- system prompt (prepended to message as XML block)
|
|
- format arg (--format)
|
|
- the prompt itself
|
|
"""
|
|
args = [self._config.command, "run"]
|
|
|
|
# Model selection
|
|
if self._config.model:
|
|
args.extend(["--model", self._config.model])
|
|
|
|
# Session continuity — like OpenClaw's sessionArg
|
|
existing_session = None
|
|
if conversation_id:
|
|
existing_session = self._sessions.get(conversation_id)
|
|
|
|
if existing_session:
|
|
# Continue an existing session
|
|
args.extend(["--continue", "--session", existing_session])
|
|
# For new conversations, OpenCode creates a new session automatically
|
|
|
|
# Output format — use JSON for structured parsing, default for plain text
|
|
# Valid choices: "default" (formatted), "json" (raw JSON events)
|
|
if self._config.format and self._config.format in ("default", "json"):
|
|
args.extend(["--format", self._config.format])
|
|
|
|
# Build the full prompt — prepend system prompt if provided
|
|
# opencode run doesn't have a --system-prompt flag, so we inject it
|
|
# as an XML-tagged block before the user message
|
|
if system_prompt:
|
|
full_message = (
|
|
f"<system_instructions>\n{system_prompt}\n</system_instructions>\n\n"
|
|
f"<user_message>\n{message}\n</user_message>"
|
|
)
|
|
else:
|
|
full_message = message
|
|
|
|
# The prompt message (must come last as a positional arg)
|
|
args.append(full_message)
|
|
|
|
return args
|
|
|
|
def _build_cli_env(self) -> dict[str, str]:
|
|
"""
|
|
Build environment variables for the CLI subprocess.
|
|
|
|
Note: OpenCode reads OPENCODE_* env vars as config overrides and
|
|
tries to parse their values as JSON. We must NOT set arbitrary
|
|
OPENCODE_* vars here — only pass through the parent environment.
|
|
"""
|
|
env = os.environ.copy()
|
|
return env
|
|
|
|
def _parse_cli_output(self, stdout: str) -> str:
|
|
"""
|
|
Parse CLI output to extract the response text.
|
|
|
|
OpenCode's `--format json` emits JSONL (one JSON object per line):
|
|
{"type":"step_start", "sessionID":"ses_...", "part":{...}}
|
|
{"type":"text", "sessionID":"ses_...", "part":{"type":"text","text":"Hello!"}}
|
|
{"type":"step_finish","sessionID":"ses_...", "part":{"type":"step-finish",...}}
|
|
|
|
We extract text from events where type == "text" and part.text exists.
|
|
"""
|
|
if not stdout.strip():
|
|
return ""
|
|
|
|
# Parse JSONL lines — collect text from "text" type events
|
|
lines = stdout.strip().split("\n")
|
|
texts = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
if not isinstance(event, dict):
|
|
continue
|
|
|
|
# OpenCode event format: extract text from part.text
|
|
event_type = event.get("type", "")
|
|
part = event.get("part", {})
|
|
|
|
if event_type == "text" and isinstance(part, dict):
|
|
text = part.get("text", "")
|
|
if text:
|
|
texts.append(text)
|
|
continue
|
|
|
|
# Fallback: try generic text extraction (for non-OpenCode formats)
|
|
text = self._collect_text(event)
|
|
if text:
|
|
texts.append(text)
|
|
|
|
except json.JSONDecodeError:
|
|
# Not JSON — might be plain text output (--format default)
|
|
texts.append(line)
|
|
|
|
if texts:
|
|
return "\n".join(texts)
|
|
|
|
# Final fallback to raw text
|
|
return stdout.strip()
|
|
|
|
def _collect_text(self, value: Any) -> str:
|
|
"""
|
|
Recursively collect text from a parsed JSON object.
|
|
Adapted from OpenClaw's collectText() from helpers.ts,
|
|
with awareness of OpenCode's event structure.
|
|
"""
|
|
if not value:
|
|
return ""
|
|
|
|
if isinstance(value, str):
|
|
return value
|
|
|
|
if isinstance(value, list):
|
|
return "".join(self._collect_text(item) for item in value)
|
|
|
|
if isinstance(value, dict):
|
|
# Skip OpenCode event wrapper — dig into "part" first
|
|
if "part" in value and isinstance(value["part"], dict):
|
|
part = value["part"]
|
|
if "text" in part and isinstance(part["text"], str):
|
|
return part["text"]
|
|
|
|
# Try common text fields
|
|
if "content" in value and isinstance(value["content"], str):
|
|
return value["content"]
|
|
if "content" in value and isinstance(value["content"], list):
|
|
return "".join(
|
|
self._collect_text(item) for item in value["content"]
|
|
)
|
|
if "message" in value and isinstance(value["message"], dict):
|
|
return self._collect_text(value["message"])
|
|
if "result" in value:
|
|
return self._collect_text(value["result"])
|
|
|
|
return ""
|
|
|
|
def _extract_session_id(self, stdout: str) -> str | None:
|
|
"""
|
|
Extract session ID from CLI output.
|
|
|
|
OpenCode includes sessionID in every JSONL event line:
|
|
{"type":"text", "sessionID":"ses_abc123", ...}
|
|
|
|
We grab it from the first event that has one.
|
|
"""
|
|
lines = stdout.strip().split("\n")
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
if not isinstance(event, dict):
|
|
continue
|
|
|
|
# OpenCode format: top-level sessionID
|
|
session_id = event.get("sessionID")
|
|
if isinstance(session_id, str) and session_id.strip():
|
|
return session_id.strip()
|
|
|
|
# Fallback: check nested part.sessionID
|
|
part = event.get("part", {})
|
|
if isinstance(part, dict):
|
|
session_id = part.get("sessionID")
|
|
if isinstance(session_id, str) and session_id.strip():
|
|
return session_id.strip()
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
return None
|
|
|
|
# -------------------------------------------------------------------
|
|
# SDK Mode: OpenCode serve API
|
|
# (enhanced version of CLI mode, using the official Python SDK)
|
|
# -------------------------------------------------------------------
|
|
|
|
def _init_sdk_client(self) -> None:
|
|
"""Initialize the OpenCode Python SDK client."""
|
|
try:
|
|
from opencode_ai import Opencode
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"base_url": self._config.server_url,
|
|
}
|
|
|
|
if self._config.server_password:
|
|
import httpx
|
|
|
|
kwargs["http_client"] = httpx.Client(
|
|
auth=(
|
|
self._config.server_username,
|
|
self._config.server_password,
|
|
)
|
|
)
|
|
|
|
self._sdk_client = Opencode(**kwargs)
|
|
|
|
# Test connectivity
|
|
try:
|
|
self._sdk_client.app.get()
|
|
self._sdk_available = True
|
|
logger.info(
|
|
f"SDK connected to {self._config.server_url}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"SDK connection test failed: {e}. "
|
|
f"Will fall back to CLI mode."
|
|
)
|
|
self._sdk_available = False
|
|
|
|
except ImportError:
|
|
logger.warning(
|
|
"opencode-ai SDK not installed. "
|
|
"Install with: pip install opencode-ai. "
|
|
"Falling back to CLI mode."
|
|
)
|
|
self._sdk_available = False
|
|
|
|
def _chat_sdk(
|
|
self,
|
|
message: str,
|
|
conversation_id: str | None = None,
|
|
system_prompt: str | None = None,
|
|
) -> AgentResponse:
|
|
"""
|
|
Chat using the OpenCode Python SDK.
|
|
|
|
Uses the server API:
|
|
1. Create or reuse a session (POST /session)
|
|
2. Send a message (POST /session/:id/message → client.session.chat)
|
|
3. Parse the AssistantMessage response
|
|
"""
|
|
if not self._sdk_client:
|
|
return self._chat_cli(message, conversation_id, system_prompt)
|
|
|
|
try:
|
|
# Resolve or create session
|
|
session_id = None
|
|
if conversation_id:
|
|
session_id = self._sessions.get(conversation_id)
|
|
|
|
if not session_id:
|
|
# Create a new session
|
|
session = self._sdk_client.session.create()
|
|
session_id = session.id
|
|
if conversation_id:
|
|
self._sessions.set(conversation_id, session_id)
|
|
logger.info(f"SDK: created session {session_id}")
|
|
|
|
# Build message parts
|
|
parts = [{"type": "text", "text": message}]
|
|
|
|
# Build chat params
|
|
chat_kwargs: dict[str, Any] = {"parts": parts}
|
|
|
|
if self._config.model:
|
|
chat_kwargs["model"] = self._config.model
|
|
|
|
if system_prompt:
|
|
chat_kwargs["system"] = system_prompt
|
|
|
|
# Send message and get response
|
|
logger.info(
|
|
f"SDK chat: session={session_id[:8]}... "
|
|
f"prompt_chars={len(message)}"
|
|
)
|
|
|
|
response = self._sdk_client.session.chat(
|
|
session_id, **chat_kwargs
|
|
)
|
|
|
|
# Extract text from the AssistantMessage response
|
|
response_text = self._extract_sdk_response_text(response)
|
|
|
|
return AgentResponse(
|
|
text=response_text,
|
|
session_id=session_id,
|
|
model=self._config.model,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"SDK chat failed: {e}. Falling back to CLI mode."
|
|
)
|
|
# Graceful fallback to CLI mode
|
|
return self._chat_cli(message, conversation_id, system_prompt)
|
|
|
|
def _extract_sdk_response_text(self, response: Any) -> str:
|
|
"""Extract text content from the SDK's AssistantMessage response."""
|
|
# The response is an AssistantMessage which has parts
|
|
if hasattr(response, "parts"):
|
|
texts = []
|
|
for part in response.parts:
|
|
if hasattr(part, "text"):
|
|
texts.append(part.text)
|
|
elif hasattr(part, "content"):
|
|
texts.append(str(part.content))
|
|
return "\n".join(texts).strip()
|
|
|
|
# Fallback: try to get text directly
|
|
if hasattr(response, "text"):
|
|
return response.text.strip()
|
|
|
|
# Last resort: stringify
|
|
return str(response).strip()
|
|
|
|
# -------------------------------------------------------------------
|
|
# Validation & Utilities
|
|
# -------------------------------------------------------------------
|
|
|
|
def _validate_installation(self) -> None:
|
|
"""Check that OpenCode CLI is installed and accessible."""
|
|
cmd = self._config.command
|
|
|
|
# If the resolved command doesn't exist, try resolving again
|
|
if not os.path.isfile(cmd) and not shutil.which(cmd):
|
|
resolved = _resolve_opencode_command()
|
|
if resolved != "opencode" and os.path.isfile(resolved):
|
|
self._config.command = resolved
|
|
logger.info(f"Resolved opencode binary: {resolved}")
|
|
else:
|
|
logger.warning(
|
|
f"'{cmd}' not found. "
|
|
f"Install with: curl -fsSL https://opencode.ai/install | bash "
|
|
f"or: npm install -g opencode-ai"
|
|
)
|
|
if self._config.mode == RuntimeMode.CLI:
|
|
logger.warning(
|
|
"CLI mode requires opencode to be installed. "
|
|
"If using SDK mode, set OPENCODE_MODE=sdk."
|
|
)
|
|
|
|
def _is_opencode_available(self) -> bool:
|
|
"""Check if OpenCode CLI is available."""
|
|
try:
|
|
result = subprocess.run(
|
|
[self._config.command, "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# System Prompt Builder
|
|
# (Mirrors OpenClaw's buildSystemPrompt in cli-runner/helpers.ts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_aetheel_system_prompt(
|
|
user_name: str | None = None,
|
|
channel_name: str | None = None,
|
|
is_dm: bool = False,
|
|
extra_context: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Build the system prompt for Aetheel.
|
|
|
|
Like OpenClaw's buildAgentSystemPrompt(), this constructs a comprehensive
|
|
prompt that gives the AI its identity, capabilities, and context.
|
|
"""
|
|
lines = [
|
|
"You are Aetheel — a personal AI assistant that lives inside Slack.",
|
|
"",
|
|
"# Identity",
|
|
"- Your name is Aetheel",
|
|
"- You ARE a Slack bot — you are already running inside Slack right now",
|
|
"- You have your own Slack bot token and can send messages to any channel",
|
|
"- You have a persistent memory system with identity files (SOUL.md, USER.md, MEMORY.md)",
|
|
"- You can read and update your memory files across sessions",
|
|
"",
|
|
"# Your Capabilities",
|
|
"- **Direct messaging**: You are already in the user's Slack workspace — no setup needed",
|
|
"- **Memory**: You have SOUL.md (your personality), USER.md (user profile), MEMORY.md (long-term memory)",
|
|
"- **Session logs**: Conversations are automatically saved to daily/ session files",
|
|
"- **Reminders**: You can schedule messages to be sent later using action tags (see below)",
|
|
"",
|
|
"# Action Tags",
|
|
"You can perform actions by including special tags in your response.",
|
|
"The system will parse these tags and execute the actions automatically.",
|
|
"",
|
|
"## Reminders",
|
|
"To schedule a reminder, include this tag anywhere in your response:",
|
|
"```",
|
|
"[ACTION:remind|<minutes>|<message>]",
|
|
"```",
|
|
"Example: `[ACTION:remind|2|Time to drink water! 💧]` — sends a Slack message in 2 minutes",
|
|
"Example: `[ACTION:remind|30|Stand up and stretch! 🧘]` — sends a message in 30 minutes",
|
|
"",
|
|
"When scheduling a reminder, confirm to the user that it's been set,",
|
|
"and include the action tag in your response (it will be hidden from the user).",
|
|
"",
|
|
"# Your Tools",
|
|
"- You have access to shell commands, file operations, and web search",
|
|
"- Use web search to look up current information when needed",
|
|
"- You can read and write files in the workspace (~/.aetheel/workspace/)",
|
|
"- You can execute shell commands for system tasks",
|
|
"",
|
|
"# Self-Modification",
|
|
"- You can edit your own config at ~/.aetheel/config.json",
|
|
"- You can create new skills by writing SKILL.md files to ~/.aetheel/workspace/skills/<name>/SKILL.md",
|
|
"- You can update your identity files (SOUL.md, USER.md, MEMORY.md)",
|
|
"- You can modify HEARTBEAT.md to change your periodic tasks",
|
|
"- After editing config, tell the user to restart or use /reload",
|
|
"",
|
|
"# Subagents & Teams",
|
|
"- You can spawn background subagents for long-running tasks using [ACTION:spawn|<task>]",
|
|
"- You can use Team tools (TeamCreate, SendMessage) for multi-agent coordination",
|
|
"- Use /subagents to list active background tasks",
|
|
"",
|
|
"# Guidelines",
|
|
"- Be helpful, concise, and friendly",
|
|
"- Use Slack formatting (bold with *text*, code with `text`, etc.)",
|
|
"- Keep responses focused and relevant",
|
|
"- If you don't know something, say so honestly",
|
|
"- Avoid extremely long responses unless asked for detail",
|
|
"- NEVER ask for Slack tokens, webhook URLs, or API keys — you already have them",
|
|
"- NEVER suggest the user 'set up' Slack — you ARE the Slack bot",
|
|
"",
|
|
"# Context",
|
|
]
|
|
|
|
if user_name:
|
|
lines.append(f"- You are chatting with: {user_name}")
|
|
if channel_name and not is_dm:
|
|
lines.append(f"- Channel: #{channel_name}")
|
|
if is_dm:
|
|
lines.append("- This is a direct message (private conversation)")
|
|
|
|
if extra_context:
|
|
lines.append("")
|
|
lines.append(extra_context)
|
|
|
|
return "\n".join(lines)
|