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
206 lines
6.3 KiB
Python
206 lines
6.3 KiB
Python
"""
|
|
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
|