refactor(heartbeat): config-driven checks, channel delivery, remove hardcoded values
Heartbeat system overhaul: - Fix email/calendar checks to parse JSON output correctly - Add per-check cooldowns and quiet hours config - Send findings to Discord channel instead of just logging - Auto-reindex KB when stale files detected - Claude CLI called only if HEARTBEAT.md has extra instructions - All settings configurable via config.json heartbeat section Move hardcoded values to config.json: - allowed_tools list (claude_session.py) - Ollama URL/model (memory_search.py now reads ollama.url from config) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
281
src/heartbeat.py
281
src/heartbeat.py
@@ -1,4 +1,9 @@
|
||||
"""Echo Core heartbeat — periodic health checks."""
|
||||
"""Echo Core heartbeat — periodic health checks.
|
||||
|
||||
Python checks are configured via config.json heartbeat section.
|
||||
If personality/HEARTBEAT.md has extra instructions beyond basic rules,
|
||||
a Claude CLI session is triggered to handle them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -11,51 +16,114 @@ log = logging.getLogger(__name__)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE_FILE = PROJECT_ROOT / "memory" / "heartbeat-state.json"
|
||||
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||
HEARTBEAT_MD = PROJECT_ROOT / "personality" / "HEARTBEAT.md"
|
||||
|
||||
# Defaults (overridable via config.json heartbeat section)
|
||||
DEFAULT_CHECKS = {
|
||||
"email": True,
|
||||
"calendar": True,
|
||||
"kb_index": True,
|
||||
"git": True,
|
||||
}
|
||||
|
||||
DEFAULT_COOLDOWNS = {
|
||||
"email": 1800, # 30 min
|
||||
"calendar": 0, # every run
|
||||
"kb_index": 14400, # 4h
|
||||
"git": 14400, # 4h
|
||||
}
|
||||
|
||||
DEFAULT_QUIET_HOURS = [23, 8]
|
||||
|
||||
|
||||
def run_heartbeat(quiet_hours: tuple[int, int] = (23, 8)) -> str:
|
||||
def run_heartbeat(config: dict | None = None) -> str:
|
||||
"""Run all heartbeat checks. Returns summary string.
|
||||
|
||||
During quiet hours, returns "HEARTBEAT_OK" unless something critical.
|
||||
Config is read from config["heartbeat"]. Python checks run first,
|
||||
then Claude CLI is called if HEARTBEAT.md has extra instructions.
|
||||
"""
|
||||
hb_config = (config or {}).get("heartbeat", {})
|
||||
quiet_hours = tuple(hb_config.get("quiet_hours", DEFAULT_QUIET_HOURS))
|
||||
check_flags = {**DEFAULT_CHECKS, **hb_config.get("checks", {})}
|
||||
cooldowns = {**DEFAULT_COOLDOWNS, **hb_config.get("cooldowns", {})}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
hour = datetime.now().hour # local hour
|
||||
is_quiet = _is_quiet_hour(hour, quiet_hours)
|
||||
|
||||
state = _load_state()
|
||||
checks = state.setdefault("checks", {})
|
||||
results = []
|
||||
critical = []
|
||||
|
||||
# Check 1: Email
|
||||
email_result = _check_email(state)
|
||||
if email_result:
|
||||
results.append(email_result)
|
||||
if check_flags.get("email") and _should_run("email", checks, now, cooldowns):
|
||||
email_result = _check_email(state)
|
||||
if email_result:
|
||||
results.append(email_result)
|
||||
checks["email"] = now.isoformat()
|
||||
|
||||
# Check 2: Calendar
|
||||
cal_result = _check_calendar(state)
|
||||
if cal_result:
|
||||
results.append(cal_result)
|
||||
# Check 2: Calendar (critical — pierces quiet hours)
|
||||
if check_flags.get("calendar"):
|
||||
cal_result = _check_calendar(state)
|
||||
if cal_result:
|
||||
critical.append(cal_result)
|
||||
checks["calendar"] = now.isoformat()
|
||||
|
||||
# Check 3: KB index freshness
|
||||
kb_result = _check_kb_index()
|
||||
if kb_result:
|
||||
results.append(kb_result)
|
||||
# Check 3: KB index freshness + auto-reindex
|
||||
if check_flags.get("kb_index") and _should_run("kb_index", checks, now, cooldowns):
|
||||
kb_result = _check_kb_index()
|
||||
if kb_result:
|
||||
results.append(kb_result)
|
||||
checks["kb_index"] = now.isoformat()
|
||||
|
||||
# Check 4: Git status
|
||||
git_result = _check_git()
|
||||
if git_result:
|
||||
results.append(git_result)
|
||||
if check_flags.get("git") and _should_run("git", checks, now, cooldowns):
|
||||
git_result = _check_git()
|
||||
if git_result:
|
||||
results.append(git_result)
|
||||
checks["git"] = now.isoformat()
|
||||
|
||||
# Claude CLI: run if HEARTBEAT.md has extra instructions
|
||||
if not is_quiet:
|
||||
claude_result = _run_claude_extra(
|
||||
hb_config, critical + results, is_quiet
|
||||
)
|
||||
if claude_result:
|
||||
results.append(claude_result)
|
||||
|
||||
# Update state
|
||||
state["last_run"] = now.isoformat()
|
||||
_save_state(state)
|
||||
|
||||
if not results:
|
||||
return "HEARTBEAT_OK"
|
||||
|
||||
# Critical items always get through (even quiet hours)
|
||||
if is_quiet:
|
||||
if critical:
|
||||
return " | ".join(critical)
|
||||
return "HEARTBEAT_OK"
|
||||
|
||||
return " | ".join(results)
|
||||
all_results = critical + results
|
||||
if not all_results:
|
||||
return "HEARTBEAT_OK"
|
||||
|
||||
return " | ".join(all_results)
|
||||
|
||||
|
||||
def _should_run(check_name: str, checks: dict, now: datetime,
|
||||
cooldowns: dict | None = None) -> bool:
|
||||
"""Check if enough time has passed since last run of this check."""
|
||||
cd = cooldowns or DEFAULT_COOLDOWNS
|
||||
cooldown = cd.get(check_name, 0)
|
||||
if cooldown == 0:
|
||||
return True
|
||||
last_run_str = checks.get(check_name)
|
||||
if not last_run_str:
|
||||
return True
|
||||
try:
|
||||
last_run = datetime.fromisoformat(last_run_str)
|
||||
return (now - last_run).total_seconds() >= cooldown
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
def _is_quiet_hour(hour: int, quiet_hours: tuple[int, int]) -> bool:
|
||||
@@ -67,7 +135,7 @@ def _is_quiet_hour(hour: int, quiet_hours: tuple[int, int]) -> bool:
|
||||
|
||||
|
||||
def _check_email(state: dict) -> str | None:
|
||||
"""Check for new emails via tools/email_check.py."""
|
||||
"""Check for new emails via tools/email_check.py. Parses JSON output."""
|
||||
script = TOOLS_DIR / "email_check.py"
|
||||
if not script.exists():
|
||||
return None
|
||||
@@ -77,18 +145,34 @@ def _check_email(state: dict) -> str | None:
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output and output != "0":
|
||||
return f"Email: {output}"
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return None
|
||||
data = json.loads(output)
|
||||
if not data.get("ok"):
|
||||
return None
|
||||
count = data.get("unread_count", 0)
|
||||
if count == 0:
|
||||
return None
|
||||
emails = data.get("emails", [])
|
||||
subjects = [e.get("subject", "?") for e in emails[:5]]
|
||||
subject_list = ", ".join(subjects)
|
||||
return f"Email: {count} necitite ({subject_list})"
|
||||
except json.JSONDecodeError:
|
||||
# Fallback: treat as plain text
|
||||
output = result.stdout.strip()
|
||||
if output and output != "0":
|
||||
return f"Email: {output}"
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning(f"Email check failed: {e}")
|
||||
log.warning("Email check failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _check_calendar(state: dict) -> str | None:
|
||||
"""Check upcoming calendar events via tools/calendar_check.py."""
|
||||
"""Check upcoming calendar events via tools/calendar_check.py. Parses JSON."""
|
||||
script = TOOLS_DIR / "calendar_check.py"
|
||||
if not script.exists():
|
||||
return None
|
||||
@@ -98,21 +182,39 @@ def _check_calendar(state: dict) -> str | None:
|
||||
capture_output=True, text=True, timeout=30,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output:
|
||||
return f"Calendar: {output}"
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return None
|
||||
data = json.loads(output)
|
||||
upcoming = data.get("upcoming", [])
|
||||
if not upcoming:
|
||||
return None
|
||||
parts = []
|
||||
for event in upcoming:
|
||||
mins = event.get("minutes_until", "?")
|
||||
name = event.get("summary", "?")
|
||||
time = event.get("time", "")
|
||||
parts.append(f"in {mins} min — {name} ({time})")
|
||||
return "Calendar: " + "; ".join(parts)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback: treat as plain text
|
||||
output = result.stdout.strip()
|
||||
if output:
|
||||
return f"Calendar: {output}"
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning(f"Calendar check failed: {e}")
|
||||
log.warning("Calendar check failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _check_kb_index() -> str | None:
|
||||
"""Check if any .md files in memory/kb/ are newer than index.json."""
|
||||
"""Check if .md files in memory/kb/ are newer than index.json. Auto-reindex."""
|
||||
index_file = PROJECT_ROOT / "memory" / "kb" / "index.json"
|
||||
if not index_file.exists():
|
||||
return "KB: index missing"
|
||||
_run_reindex()
|
||||
return "KB: index regenerat"
|
||||
|
||||
index_mtime = index_file.stat().st_mtime
|
||||
kb_dir = PROJECT_ROOT / "memory" / "kb"
|
||||
@@ -123,10 +225,27 @@ def _check_kb_index() -> str | None:
|
||||
newer += 1
|
||||
|
||||
if newer > 0:
|
||||
return f"KB: {newer} files need reindex"
|
||||
_run_reindex()
|
||||
return f"KB: {newer} fișiere reindexate"
|
||||
return None
|
||||
|
||||
|
||||
def _run_reindex() -> None:
|
||||
"""Run tools/update_notes_index.py to regenerate KB index."""
|
||||
script = TOOLS_DIR / "update_notes_index.py"
|
||||
if not script.exists():
|
||||
log.warning("KB reindex script not found: %s", script)
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["python3", str(script)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
cwd=str(PROJECT_ROOT)
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("KB reindex failed: %s", e)
|
||||
|
||||
|
||||
def _check_git() -> str | None:
|
||||
"""Check for uncommitted files in project."""
|
||||
try:
|
||||
@@ -144,6 +263,96 @@ def _check_git() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_extra_instructions() -> str | None:
|
||||
"""Read HEARTBEAT.md and return extra instructions if any.
|
||||
|
||||
Skips the basic structure (title, quiet hours rules).
|
||||
Returns None if only boilerplate remains.
|
||||
"""
|
||||
if not HEARTBEAT_MD.exists():
|
||||
return None
|
||||
content = HEARTBEAT_MD.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return None
|
||||
# Strip lines that are just headers, empty, or the standard rules
|
||||
meaningful = []
|
||||
for line in content.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("# HEARTBEAT"):
|
||||
continue
|
||||
if stripped.startswith("## Reguli"):
|
||||
continue
|
||||
if "HEARTBEAT_OK" in stripped:
|
||||
continue
|
||||
if "quiet" in stripped.lower() or "noapte" in stripped.lower():
|
||||
continue
|
||||
if "nu spama" in stripped.lower() or "nu deranja" in stripped.lower():
|
||||
continue
|
||||
meaningful.append(line)
|
||||
if not meaningful:
|
||||
return None
|
||||
return "\n".join(meaningful).strip()
|
||||
|
||||
|
||||
def _run_claude_extra(hb_config: dict, python_results: list[str],
|
||||
is_quiet: bool) -> str | None:
|
||||
"""Run Claude CLI if HEARTBEAT.md has extra instructions."""
|
||||
from src.claude_session import CLAUDE_BIN, _safe_env
|
||||
|
||||
extra = _get_extra_instructions()
|
||||
if not extra:
|
||||
return None
|
||||
|
||||
model = hb_config.get("model", "haiku")
|
||||
|
||||
# Build prompt with context
|
||||
context_parts = ["Heartbeat tick."]
|
||||
if python_results:
|
||||
context_parts.append(
|
||||
f"Check-uri Python: {' | '.join(python_results)}"
|
||||
)
|
||||
else:
|
||||
context_parts.append("Check-urile Python nu au găsit nimic.")
|
||||
context_parts.append(f"Instrucțiuni extra din HEARTBEAT.md:\n{extra}")
|
||||
context_parts.append(
|
||||
"Execută instrucțiunile de mai sus. "
|
||||
"Răspunde DOAR cu rezultatul (scurt, fără explicații). "
|
||||
"Dacă nu e nimic de raportat, răspunde cu HEARTBEAT_OK."
|
||||
)
|
||||
prompt = "\n\n".join(context_parts)
|
||||
|
||||
cmd = [
|
||||
CLAUDE_BIN, "-p", prompt,
|
||||
"--model", model,
|
||||
"--output-format", "json",
|
||||
]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env=_safe_env(),
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log.warning("Claude heartbeat extra failed (exit %d): %s",
|
||||
proc.returncode, proc.stderr[:200])
|
||||
return None
|
||||
data = json.loads(proc.stdout)
|
||||
result = data.get("result", "").strip()
|
||||
if not result or result == "HEARTBEAT_OK":
|
||||
return None
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("Claude heartbeat extra timed out")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning("Claude heartbeat extra error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
"""Load heartbeat state from JSON file."""
|
||||
if STATE_FILE.exists():
|
||||
|
||||
Reference in New Issue
Block a user