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:
205
heartbeat/heartbeat.py
Normal file
205
heartbeat/heartbeat.py
Normal 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
|
||||
Reference in New Issue
Block a user