feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover

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
This commit is contained in:
2026-02-18 01:07:12 -05:00
parent 41b2f9a593
commit 6d73f74e0b
41 changed files with 11363 additions and 437 deletions

3
heartbeat/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from heartbeat.heartbeat import HeartbeatRunner, HeartbeatTask
__all__ = ["HeartbeatRunner", "HeartbeatTask"]

205
heartbeat/heartbeat.py Normal file
View File

@@ -0,0 +1,205 @@
"""
Aetheel Heartbeat System
========================
Parses HEARTBEAT.md and registers periodic tasks with the Scheduler.
Each section in HEARTBEAT.md defines a schedule (natural language header)
and one or more task prompts (bullet points). The HeartbeatRunner converts
these into cron jobs that fire synthetic messages through the AI handler.
"""
import logging
import os
import re
from dataclasses import dataclass
from config import HeartbeatConfig
logger = logging.getLogger("aetheel.heartbeat")
@dataclass
class HeartbeatTask:
cron_expr: str
prompt: str
section_name: str
DEFAULT_HEARTBEAT_MD = """\
# Heartbeat Tasks
## Every 30 minutes
- Check if any scheduled reminders need attention
## Every morning (9:00 AM)
- Summarize yesterday's conversations
## Every evening (6:00 PM)
- Update MEMORY.md with today's key learnings
"""
class HeartbeatRunner:
"""Parses HEARTBEAT.md and registers tasks with the Scheduler."""
def __init__(
self,
scheduler,
ai_handler_fn,
send_fn,
config: HeartbeatConfig,
workspace_dir: str,
):
self._scheduler = scheduler
self._ai_handler = ai_handler_fn
self._send_fn = send_fn
self._config = config
self._workspace_dir = workspace_dir
self._heartbeat_path = os.path.join(workspace_dir, "HEARTBEAT.md")
def start(self) -> int:
"""Parse HEARTBEAT.md and register tasks. Returns count registered."""
if not self._config.enabled:
return 0
self._ensure_heartbeat_file()
tasks = self._parse_heartbeat_md()
for task in tasks:
self._scheduler.add_cron(
cron_expr=task.cron_expr,
prompt=task.prompt,
channel_id=self._config.default_channel_id,
channel_type="heartbeat",
)
logger.info(f"Heartbeat started: {len(tasks)} task(s) registered")
return len(tasks)
def _parse_heartbeat_md(self) -> list[HeartbeatTask]:
"""Parse HEARTBEAT.md sections into HeartbeatTask objects."""
try:
with open(self._heartbeat_path, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
logger.warning(f"HEARTBEAT.md not found at {self._heartbeat_path}")
return []
except Exception as e:
logger.warning(f"Failed to read HEARTBEAT.md: {e}")
return []
tasks: list[HeartbeatTask] = []
current_header: str | None = None
current_cron: str | None = None
for line in content.splitlines():
stripped = line.strip()
# Match ## section headers (schedule definitions)
if stripped.startswith("## "):
header_text = stripped[3:].strip()
cron = self._parse_schedule_header(header_text)
if cron is not None:
current_header = header_text
current_cron = cron
else:
logger.warning(
f"Unrecognized schedule header: '{header_text}' — skipping section"
)
current_header = None
current_cron = None
continue
# Match bullet points under a valid section
if current_cron is not None and stripped.startswith("- "):
prompt = stripped[2:].strip()
if prompt:
tasks.append(
HeartbeatTask(
cron_expr=current_cron,
prompt=prompt,
section_name=current_header or "",
)
)
return tasks
def _ensure_heartbeat_file(self) -> None:
"""Create default HEARTBEAT.md if it doesn't exist."""
if os.path.exists(self._heartbeat_path):
return
os.makedirs(os.path.dirname(self._heartbeat_path), exist_ok=True)
with open(self._heartbeat_path, "w", encoding="utf-8") as f:
f.write(DEFAULT_HEARTBEAT_MD)
logger.info(f"Created default HEARTBEAT.md at {self._heartbeat_path}")
@staticmethod
def _parse_schedule_header(header: str) -> str | None:
"""Convert a natural language schedule header to a cron expression.
Supported patterns:
"Every 30 minutes" -> */30 * * * *
"Every N minutes" -> */N * * * *
"Every hour" -> 0 * * * *
"Every N hours" -> 0 */N * * *
"Every morning (9:00 AM)" -> 0 9 * * *
"Every evening (6:00 PM)" -> 0 18 * * *
Returns None for unrecognized patterns.
"""
h = header.strip()
# "Every hour"
if re.match(r"^every\s+hour$", h, re.IGNORECASE):
return "0 * * * *"
# "Every N minutes"
m = re.match(r"^every\s+(\d+)\s+minutes?$", h, re.IGNORECASE)
if m:
n = int(m.group(1))
return f"*/{n} * * * *"
# "Every N hours"
m = re.match(r"^every\s+(\d+)\s+hours?$", h, re.IGNORECASE)
if m:
n = int(m.group(1))
return f"0 */{n} * * *"
# "Every morning (H:MM AM)" or "Every morning (H AM)"
m = re.match(
r"^every\s+morning\s*\(\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*\)$",
h,
re.IGNORECASE,
)
if m:
hour = int(m.group(1))
minute = int(m.group(2)) if m.group(2) else 0
period = m.group(3).upper()
hour = _to_24h(hour, period)
return f"{minute} {hour} * * *"
# "Every evening (H:MM PM)" or "Every evening (H PM)"
m = re.match(
r"^every\s+evening\s*\(\s*(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*\)$",
h,
re.IGNORECASE,
)
if m:
hour = int(m.group(1))
minute = int(m.group(2)) if m.group(2) else 0
period = m.group(3).upper()
hour = _to_24h(hour, period)
return f"{minute} {hour} * * *"
return None
def _to_24h(hour: int, period: str) -> int:
"""Convert 12-hour time to 24-hour."""
if period == "AM":
return 0 if hour == 12 else hour
else: # PM
return hour if hour == 12 else hour + 12