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:
MoltBot Service
2026-02-14 23:46:04 +00:00
parent ad31b25af3
commit 9c1f9f94e7
6 changed files with 588 additions and 141 deletions

View File

@@ -47,62 +47,50 @@ PERSONALITY_FILES = [
]
# Tools allowed in non-interactive (-p) mode.
# NOTE: curl/wget intentionally excluded (data exfiltration risk).
# Use WebFetch/WebSearch for safe, read-only web access.
# SSH/SCP/rsync restricted to local network (10.0.20.*).
ALLOWED_TOOLS = [
# Loaded from config.json "allowed_tools" at init, with hardcoded defaults.
_DEFAULT_ALLOWED_TOOLS = [
"Read", "Edit", "Write", "Glob", "Grep",
# Read-only web (safe — cannot POST data)
"WebFetch",
"WebSearch",
# Python scripts
"Bash(python3 *)",
"Bash(.venv/bin/python3 *)",
"Bash(pip *)",
"Bash(pytest *)",
# Git — both bare commands and commands with arguments
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git push)",
"Bash(git push *)",
"Bash(git pull)",
"Bash(git pull *)",
"Bash(git status)",
"Bash(git status *)",
"Bash(git diff)",
"Bash(git diff *)",
"Bash(git log)",
"Bash(git log *)",
"WebFetch", "WebSearch",
"Bash(python3 *)", "Bash(.venv/bin/python3 *)",
"Bash(pip *)", "Bash(pytest *)",
"Bash(git add *)", "Bash(git commit *)",
"Bash(git push)", "Bash(git push *)",
"Bash(git pull)", "Bash(git pull *)",
"Bash(git status)", "Bash(git status *)",
"Bash(git diff)", "Bash(git diff *)",
"Bash(git log)", "Bash(git log *)",
"Bash(git checkout *)",
"Bash(git branch)",
"Bash(git branch *)",
"Bash(git stash)",
"Bash(git stash *)",
# Node/npm
"Bash(npm *)",
"Bash(node *)",
"Bash(npx *)",
# System
"Bash(git branch)", "Bash(git branch *)",
"Bash(git stash)", "Bash(git stash *)",
"Bash(npm *)", "Bash(node *)", "Bash(npx *)",
"Bash(systemctl --user *)",
"Bash(trash *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(chmod *)",
# Docker (local daemon only)
"Bash(docker *)",
"Bash(docker-compose *)",
"Bash(docker compose *)",
# SSH — local network only (no external hosts)
"Bash(ssh *@10.0.20.*)",
"Bash(ssh root@10.0.20.*)",
"Bash(trash *)", "Bash(mkdir *)", "Bash(cp *)",
"Bash(mv *)", "Bash(ls *)", "Bash(cat *)", "Bash(chmod *)",
"Bash(docker *)", "Bash(docker-compose *)", "Bash(docker compose *)",
"Bash(ssh *@10.0.20.*)", "Bash(ssh root@10.0.20.*)",
"Bash(ssh echo@10.0.20.*)",
"Bash(scp *10.0.20.*)",
"Bash(rsync *10.0.20.*)",
"Bash(scp *10.0.20.*)", "Bash(rsync *10.0.20.*)",
]
def _load_allowed_tools() -> list[str]:
"""Load allowed_tools from config.json, falling back to defaults."""
config_file = PROJECT_ROOT / "config.json"
if config_file.exists():
try:
import json as _json
with open(config_file, encoding="utf-8") as f:
data = _json.load(f)
tools = data.get("allowed_tools")
if isinstance(tools, list) and tools:
return tools
except (ValueError, OSError):
pass
return list(_DEFAULT_ALLOWED_TOOLS)
ALLOWED_TOOLS = _load_allowed_tools()
# Environment variables to REMOVE from Claude subprocess
# (secrets, tokens, and vars that cause nested-session errors)
_ENV_STRIP = {

View File

@@ -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():

View File

@@ -87,12 +87,15 @@ def main():
from src.heartbeat import run_heartbeat
interval_min = hb_config.get("interval_minutes", 30)
hb_channel = hb_config.get("channel", "echo-core")
async def _heartbeat_tick() -> None:
"""Run heartbeat and log result."""
"""Run heartbeat and send findings to channel."""
try:
result = await asyncio.to_thread(run_heartbeat)
result = await asyncio.to_thread(run_heartbeat, config)
logger.info("Heartbeat: %s", result)
if result != "HEARTBEAT_OK":
await _send_to_channel(hb_channel, result)
except Exception as exc:
logger.error("Heartbeat failed: %s", exc)
@@ -105,7 +108,8 @@ def main():
max_instances=1,
)
logger.info(
"Heartbeat registered (every %d min)", interval_min
"Heartbeat registered (every %d min, channel: %s)",
interval_min, hb_channel,
)
# Telegram bot (optional — only if telegram_token exists)

View File

@@ -14,16 +14,53 @@ import httpx
log = logging.getLogger(__name__)
OLLAMA_URL = "http://10.0.20.161:11434/api/embeddings"
OLLAMA_MODEL = "all-minilm"
EMBEDDING_DIM = 384
DB_PATH = Path(__file__).resolve().parent.parent / "memory" / "echo.sqlite"
MEMORY_DIR = Path(__file__).resolve().parent.parent / "memory"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "memory" / "echo.sqlite"
MEMORY_DIR = PROJECT_ROOT / "memory"
# Defaults — overridable via config.json ollama/memory sections
_OLLAMA_BASE_URL = "http://localhost:11434"
_OLLAMA_MODEL = "all-minilm"
_EMBEDDING_DIM = 384
_CHUNK_TARGET = 500
_CHUNK_MAX = 1000
_CHUNK_MIN = 100
# Runtime config (populated by init_config)
OLLAMA_URL = f"{_OLLAMA_BASE_URL}/api/embeddings"
OLLAMA_MODEL = _OLLAMA_MODEL
EMBEDDING_DIM = _EMBEDDING_DIM
def init_config(config=None) -> None:
"""Load settings from config object. Call once at startup."""
global OLLAMA_URL, OLLAMA_MODEL, EMBEDDING_DIM
if config is None:
# Try loading from config.json directly
config_file = PROJECT_ROOT / "config.json"
if config_file.exists():
import json
try:
with open(config_file, encoding="utf-8") as f:
data = json.load(f)
base_url = data.get("ollama", {}).get("url", _OLLAMA_BASE_URL)
OLLAMA_URL = f"{base_url.rstrip('/')}/api/embeddings"
OLLAMA_MODEL = data.get("ollama", {}).get("model", _OLLAMA_MODEL)
EMBEDDING_DIM = data.get("ollama", {}).get("embedding_dim", _EMBEDDING_DIM)
except (json.JSONDecodeError, OSError):
pass
return
# Config object with .get() method
base_url = config.get("ollama.url", _OLLAMA_BASE_URL)
OLLAMA_URL = f"{base_url.rstrip('/')}/api/embeddings"
OLLAMA_MODEL = config.get("ollama.model", _OLLAMA_MODEL)
EMBEDDING_DIM = config.get("ollama.embedding_dim", _EMBEDDING_DIM)
# Auto-init from config.json on import
init_config()
def get_db() -> sqlite3.Connection:
"""Get SQLite connection, create table if needed."""