feat: openclaw-style secrets (env.vars + \) and per-task model routing

- Replace python-dotenv with config.json env.vars block + \ substitution
- Add models section for per-task model routing (heartbeat, subagent, default)
- Heartbeat/subagent tasks can use different models/providers than main chat
- Remove python-dotenv from dependencies
- Update all docs to reflect new config approach
- Reorganize docs into project/ and research/ subdirectories
This commit is contained in:
2026-02-20 23:49:05 -05:00
parent 55c6767e69
commit 82c2640481
35 changed files with 2904 additions and 422 deletions

238
main.py
View File

@@ -24,10 +24,8 @@ import sys
import threading
from datetime import datetime
from dotenv import load_dotenv
# Load .env file (secrets only — config comes from ~/.aetheel/config.json)
load_dotenv()
# Config handles secrets via config.json env.vars + ${VAR} substitution.
# No .env file needed.
from adapters.base import BaseAdapter, IncomingMessage
from adapters.slack_adapter import SlackAdapter
@@ -40,7 +38,7 @@ from agent.opencode_runtime import (
build_aetheel_system_prompt,
)
from agent.subagent import SubagentManager
from config import AetheelConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH
from config import AetheelConfig, ModelRouteConfig, load_config, save_default_config, write_mcp_config, CONFIG_PATH
from heartbeat import HeartbeatRunner
from hooks import HookManager, HookEvent
from memory import MemoryManager
@@ -172,6 +170,13 @@ def _build_context(msg: IncomingMessage) -> str:
if skills_summary:
sections.append(skills_summary)
# ── Discord channel history context ──
history_context = msg.raw_event.get("history_context", "")
if history_context:
sections.append(
f"# Recent Channel History\n\n{history_context}"
)
return "\n\n---\n\n".join(sections)
@@ -242,6 +247,15 @@ def ai_handler(msg: IncomingMessage) -> str:
if cmd.startswith("usage"):
return _handle_usage_command()
if cmd.startswith("models"):
return _handle_models_command(msg.text.strip().lstrip("/"))
if cmd.startswith("stats"):
return _handle_stats_command(msg.text.strip().lstrip("/"))
if cmd.startswith("agents") or cmd == "agent list":
return _handle_agents_command()
if cmd.startswith("mcp"):
return _handle_mcp_command(msg.text.strip().lstrip("/"))
@@ -537,6 +551,8 @@ def _handle_engine_command(text: str) -> str:
provider=cfg.runtime.provider,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
)
_runtime = OpenCodeRuntime(new_config)
@@ -608,6 +624,8 @@ def _handle_model_command(text: str) -> str:
provider=cfg.runtime.provider,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
)
_runtime = OpenCodeRuntime(new_config)
_update_config_file({"runtime": {"model": new_model}})
@@ -655,6 +673,8 @@ def _handle_provider_command(text: str) -> str:
provider=new_provider if new_provider != "auto" else None,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
)
_runtime = OpenCodeRuntime(new_config)
_update_config_file({"runtime": {"provider": new_provider}})
@@ -844,6 +864,8 @@ def _handle_rate_limit(
provider=cfg.runtime.provider,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
))
else:
# Failover: OpenCode → Claude
@@ -942,6 +964,90 @@ def _handle_usage_command() -> str:
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Models, Stats, Agents Commands
# ---------------------------------------------------------------------------
def _handle_models_command(text: str) -> str:
"""
Handle the `models` command — list available models from OpenCode.
models List all models
models <provider> List models for a specific provider
models --verbose Include metadata (costs, etc.)
"""
global _runtime, _use_claude
if _use_claude:
return "Model listing is only available with the OpenCode engine."
if not isinstance(_runtime, OpenCodeRuntime):
return "⚠️ OpenCode runtime not initialized."
parts = text.strip().split()
provider = None
verbose = False
for part in parts[1:]: # skip "models"
if part == "--verbose" or part == "-v":
verbose = True
elif not part.startswith("-"):
provider = part
output = _runtime.list_models(provider=provider, verbose=verbose)
# Wrap in code block for readability
if len(output) > 100:
return f"```\n{output[:3500]}\n```"
return output
def _handle_stats_command(text: str) -> str:
"""
Handle the `stats` command — show OpenCode token usage and cost stats.
stats All-time stats
stats 7 Stats for last 7 days
stats 30 Stats for last 30 days
"""
global _runtime, _use_claude
if _use_claude:
return "OpenCode stats are only available with the OpenCode engine. Use `usage` for Aetheel stats."
if not isinstance(_runtime, OpenCodeRuntime):
return "⚠️ OpenCode runtime not initialized."
parts = text.strip().split()
days = None
if len(parts) >= 2:
try:
days = int(parts[1])
except ValueError:
pass
output = _runtime.get_stats(days=days)
if len(output) > 100:
return f"```\n{output[:3500]}\n```"
return output
def _handle_agents_command() -> str:
"""Handle the `agents` command — list available OpenCode agents."""
global _runtime, _use_claude
if _use_claude:
return "Agent listing is only available with the OpenCode engine."
if not isinstance(_runtime, OpenCodeRuntime):
return "⚠️ OpenCode runtime not initialized."
output = _runtime.list_agents()
if len(output) > 100:
return f"```\n{output[:3500]}\n```"
return output
# ---------------------------------------------------------------------------
# MCP Server Management Commands
# ---------------------------------------------------------------------------
@@ -1254,9 +1360,14 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
Creates a synthetic IncomingMessage and routes it through ai_handler,
then sends the response to the right channel.
Heartbeat jobs use a dedicated runtime when ``models.heartbeat`` is
configured, so cheap/local models can handle periodic tasks.
"""
logger.info(f"🔔 Scheduled job firing: {job.id}'{job.prompt[:50]}'")
is_heartbeat = job.channel_type == "heartbeat"
# Build a synthetic message
msg = IncomingMessage(
text=job.prompt,
@@ -1270,9 +1381,13 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
raw_event={"thread_id": job.thread_id},
)
# Route through the AI handler
# Route through the AI handler — use heartbeat runtime if configured
try:
response = ai_handler(msg)
if is_heartbeat and _has_model_route("heartbeat"):
response = _run_with_task_runtime("heartbeat", msg)
else:
response = ai_handler(msg)
if response:
_send_to_channel(
channel_id=job.channel_id,
@@ -1284,6 +1399,39 @@ def _on_scheduled_job(job: ScheduledJob) -> None:
logger.error(f"Scheduled job {job.id} handler failed: {e}", exc_info=True)
def _has_model_route(task_type: str) -> bool:
"""Check if a model route is configured for the given task type."""
cfg = load_config()
route = getattr(cfg.models, task_type, None)
return route is not None and route.model is not None
def _run_with_task_runtime(task_type: str, msg: IncomingMessage) -> str:
"""Run a message through a task-specific runtime instance."""
runtime = _make_runtime(task_type)
system_prompt = build_aetheel_system_prompt(
user_name=msg.user_name,
channel_name=msg.channel_name,
is_dm=msg.is_dm,
extra_context=_build_context(msg),
)
response = runtime.chat(
message=msg.text,
conversation_id=msg.conversation_id,
system_prompt=system_prompt,
)
_track_usage(response)
if not response.ok:
logger.warning(f"Task runtime [{task_type}] error: {response.error}")
return f"⚠️ {task_type} task error: {response.error or 'Unknown error'}"
return _process_action_tags(response.text, msg)
# ---------------------------------------------------------------------------
# Multi-Channel Send
# ---------------------------------------------------------------------------
@@ -1324,8 +1472,19 @@ def _send_to_channel(
# ---------------------------------------------------------------------------
def _make_runtime() -> AnyRuntime:
"""Create a fresh runtime instance (used by subagent manager)."""
def _make_runtime(task_type: str | None = None) -> AnyRuntime:
"""Create a runtime instance, optionally routed by task type.
*task_type* can be ``"heartbeat"``, ``"subagent"``, or ``None``
(which falls back to the ``"default"`` route, then the global config).
Model routing is configured in ``config.json`` → ``models``::
"models": {
"heartbeat": { "model": "ollama/llama3.2", "provider": "ollama" },
"subagent": { "model": "minimax/minimax-m1", "provider": "minimax" }
}
"""
global _use_claude, _cli_args
cfg = load_config()
@@ -1333,25 +1492,41 @@ def _make_runtime() -> AnyRuntime:
cfg.runtime.model = _cli_args.model
cfg.claude.model = _cli_args.model
if _use_claude:
# Resolve model route for this task type
route: ModelRouteConfig | None = None
if task_type:
route = getattr(cfg.models, task_type, None)
if route is None:
route = cfg.models.default # may also be None → use global
# Determine engine: route overrides global
use_claude = _use_claude
if route and route.engine:
use_claude = route.engine == "claude"
if use_claude:
config = ClaudeCodeConfig(
model=cfg.claude.model,
timeout_seconds=cfg.claude.timeout_seconds,
model=(route.model if route and route.model else cfg.claude.model),
timeout_seconds=(route.timeout_seconds if route and route.timeout_seconds else cfg.claude.timeout_seconds),
max_turns=cfg.claude.max_turns,
no_tools=cfg.claude.no_tools,
allowed_tools=cfg.claude.allowed_tools,
)
logger.info(f"Runtime [{task_type or 'default'}]: claude, model={config.model or 'default'}")
return ClaudeCodeRuntime(config)
else:
config = OpenCodeConfig(
mode=RuntimeMode.SDK if cfg.runtime.mode == "sdk" else RuntimeMode.CLI,
server_url=cfg.runtime.server_url,
timeout_seconds=cfg.runtime.timeout_seconds,
model=cfg.runtime.model,
provider=cfg.runtime.provider,
timeout_seconds=(route.timeout_seconds if route and route.timeout_seconds else cfg.runtime.timeout_seconds),
model=(route.model if route and route.model else cfg.runtime.model),
provider=(route.provider if route and route.provider else cfg.runtime.provider),
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
)
logger.info(f"Runtime [{task_type or 'default'}]: opencode, model={config.model or 'default'}, provider={config.provider or 'default'}")
return OpenCodeRuntime(config)
@@ -1401,6 +1576,16 @@ def _format_status() -> str:
active = _subagent_mgr.list_active()
lines.append(f"• *Active Subagents:* {len(active)}")
# Model routing
cfg = load_config()
routes = []
for task_name in ("heartbeat", "subagent", "default"):
route = getattr(cfg.models, task_name, None)
if route and route.model:
routes.append(f"{task_name}{route.model}")
if routes:
lines.append(f"• *Model Routes:* {', '.join(routes)}")
lines.extend([
"",
f"• *Time:* {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
@@ -1428,6 +1613,11 @@ def _format_help() -> str:
"• `model` — Show/switch AI model\n"
"• `provider` — Show/switch provider (opencode only)\n"
"• `usage` — Show LLM usage stats and costs\n"
"• `models` — List available models (opencode only)\n"
"• `models <provider>` — List models for a provider\n"
"• `stats` — OpenCode token usage and cost stats\n"
"• `stats <days>` — Stats for last N days\n"
"• `agents` — List available OpenCode agents\n"
"\n"
"*Config:*\n"
"• `config` — View config summary\n"
@@ -1638,6 +1828,8 @@ CLI flags are optional overrides.
provider=cfg.runtime.provider,
workspace_dir=cfg.runtime.workspace,
format=cfg.runtime.format,
agent=cfg.runtime.agent,
attach_url=cfg.runtime.attach,
)
_runtime = OpenCodeRuntime(oc_config)
runtime_label = (
@@ -1684,7 +1876,7 @@ CLI flags are optional overrides.
if _runtime:
try:
_subagent_mgr = SubagentManager(
runtime_factory=_make_runtime,
runtime_factory=lambda: _make_runtime("subagent"),
send_fn=_send_to_channel,
)
logger.info("Subagent manager initialized")
@@ -1785,6 +1977,20 @@ CLI flags are optional overrides.
discord_adapter = DiscordAdapter(
bot_token=cfg.discord.bot_token,
listen_channels=cfg.discord.listen_channels or None,
reply_to_mode=cfg.discord.reply_to_mode,
history_enabled=cfg.discord.history_enabled,
history_limit=cfg.discord.history_limit,
channel_overrides={
k: {"history_limit": v.history_limit, "history_enabled": v.history_enabled}
for k, v in cfg.discord.channel_overrides.items()
},
ack_reaction=cfg.discord.ack_reaction,
typing_indicator=cfg.discord.typing_indicator,
reaction_mode=cfg.discord.reaction_mode,
exec_approvals=cfg.discord.exec_approvals,
exec_approval_tools=cfg.discord.exec_approval_tools,
slash_commands=cfg.discord.slash_commands,
components_enabled=cfg.discord.components_enabled,
)
discord_adapter.on_message(handler)
_adapters["discord"] = discord_adapter