feat(brain+ui+app): Triggers — passive Aufweck-Quellen fuer ARIA
ARIA hatte bisher nur ein "User fragt → Brain antwortet"-Modell. Neu:
Trigger laufen passiv im Hintergrund (kein LLM-Call) und wecken ARIA
nur dann auf wenn ein Event tatsaechlich passiert.
Drei Typen, zwei aktuell implementiert:
timer — einmalig zu festem ISO-Timestamp ("erinner mich in 10min")
watcher — Polling alle N Sek einer Condition, feuert bei True mit Throttle
(z.B. "disk_free_gb < 5", max 1x/h)
cron — Platzhalter fuer spaeter
aria-brain/triggers.py
CRUD auf /data/triggers/<name>.json + /data/triggers/logs/<name>.jsonl.
create_timer, create_watcher, mark_fired, list_logs, etc.
aria-brain/watcher.py
Built-in Condition-Variablen: disk_free_gb, disk_free_pct, uptime_sec,
hour_of_day, day_of_week, rvs_connected, memory_count.
Sicherer Condition-Parser via ast — Whitelist auf Vergleich + BoolOp +
Name + Const. Kein eval, kein exec, keine Builtins.
aria-brain/background.py
Async Loop laeuft alle 30s, sammelt einmalig Variables, geht durch
Trigger-Liste, _should_fire-Check (Timer: fires_at vergangen / Watcher:
check_interval + throttle respektiert + condition true). Fire ruft
agent.chat(prompt, source="trigger") — ARIA bekommt das wie eine
Push-Nachricht und antwortet via Bridge → RVS → App.
aria-brain/main.py
/triggers/list, /{name}, /{name}/logs, /timer, /watcher, PATCH, DELETE,
/triggers/conditions (Variablen + aktuelle Werte). Lifespan-Handler
startet den Background-Loop beim Container-Start, stoppt beim Shutdown.
aria-brain/agent.py
Meta-Tools fuer ARIA: trigger_timer, trigger_watcher, trigger_cancel,
trigger_list. ARIA legt Trigger via Tool-Call selbst an wenn Stefan das
wuenscht. Side-Channel-Event 'trigger_created' wird in chat-Response
mitgeschickt damit App + Diagnostic eine Bubble zeigen.
aria-brain/prompts.py
Neue System-Prompt-Section: Liste aktiver Triggers + verfuegbare
Condition-Variablen mit aktuellen Werten + Operatoren-Erklaerung.
ARIA weiss damit immer was es schon gibt und welche Vars sie nutzen kann.
bridge/aria_bridge.py + rvs/server.js
trigger_created als neuer RVS-Message-Type, Bridge forwarded das aus
data.events analog zu skill_created.
diagnostic/index.html
Neuer Top-Tab "Trigger". Liste mit Type-Badges (⏱ TIMER / 👁 WATCHER),
Status, Fire-Count, last_fired. Aktivieren/Deaktivieren + Löschen pro
Trigger. "+ Neu"-Modal mit Type-Dropdown, Timer-Minuten oder
Watcher-Condition + Vars-Anzeige + Throttle. Info-Modal-Eintrag mit
Erklaerung. Live-Bubble im Chat wenn ARIA selbst einen anlegt.
android/src/screens/ChatScreen.tsx
trigger_created RVS-Handler → eigene Bubble (gelber Border, "⏰ ARIA
hat einen Trigger angelegt", Type/Detail/Message/Zeit). ChatMessage
bekam triggerCreated-Feld. Lokal-only-Schutz beim Server-Sync analog
zu skill_created.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
|
||||
|
||||
Erlaubte Variablen kommen aus diesem Modul. Condition-Ausdruck ist ein
|
||||
sicheres Subset von Python (kein eval, kein exec): nur Vergleiche und
|
||||
Boolean-Operatoren, nur die hier deklarierten Variablen, nur Zahlen +
|
||||
String-Literale als rechte Seite.
|
||||
|
||||
Beispiele:
|
||||
disk_free_gb < 5
|
||||
hour_of_day == 8 and day_of_week == "mon"
|
||||
rvs_connected == False
|
||||
(disk_free_pct < 10 and uptime_sec > 3600)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Variablen-Quellen ──────────────────────────────────────────────
|
||||
|
||||
def _disk_stats() -> tuple[float, float]:
|
||||
"""Returns (free_gb, free_pct). Schaut /shared (geteiltes Volume) — sonst /."""
|
||||
target = "/shared" if os.path.exists("/shared") else "/"
|
||||
try:
|
||||
st = shutil.disk_usage(target)
|
||||
free_gb = st.free / (1024 ** 3)
|
||||
free_pct = 100.0 * st.free / st.total if st.total else 0.0
|
||||
return free_gb, free_pct
|
||||
except Exception as e:
|
||||
logger.warning("disk_usage: %s", e)
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _uptime_sec() -> int:
|
||||
try:
|
||||
with open("/proc/uptime", "r") as f:
|
||||
return int(float(f.read().split()[0]))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _rvs_connected() -> bool:
|
||||
"""Liest /shared/config/runtime.json oder ein Bridge-State-File.
|
||||
Aktuell: wir koennen das nicht zuverlaessig aus dem Brain-Container
|
||||
bestimmen — gibt False als sicheren Default zurueck.
|
||||
Spaeter: Bridge schreibt einen Heartbeat-File den wir hier lesen."""
|
||||
return False
|
||||
|
||||
|
||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
def collect_variables() -> dict[str, Any]:
|
||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen."""
|
||||
free_gb, free_pct = _disk_stats()
|
||||
now = datetime.now()
|
||||
# Memory-Count aus der Vector-DB (importiert lazy um zirkulaere Imports
|
||||
# zu vermeiden — beim Modul-Load gibt's noch keinen Store)
|
||||
memory_count = 0
|
||||
try:
|
||||
from main import store # type: ignore
|
||||
s = store()
|
||||
memory_count = s.count()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"disk_free_gb": round(free_gb, 2),
|
||||
"disk_free_pct": round(free_pct, 1),
|
||||
"uptime_sec": _uptime_sec(),
|
||||
"hour_of_day": now.hour,
|
||||
"day_of_week": _DAYS[now.weekday()],
|
||||
"rvs_connected": _rvs_connected(),
|
||||
"memory_count": memory_count,
|
||||
}
|
||||
|
||||
|
||||
def describe_variables() -> list[dict]:
|
||||
"""Liste der verfuegbaren Variablen + Beschreibung — fuer System-Prompt + UI."""
|
||||
return [
|
||||
{"name": "disk_free_gb", "type": "number", "desc": "freier Plattenplatz in GB (auf /shared)"},
|
||||
{"name": "disk_free_pct", "type": "number", "desc": "freier Plattenplatz in Prozent"},
|
||||
{"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"},
|
||||
{"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"},
|
||||
{"name": "day_of_week", "type": "string", "desc": "mon|tue|wed|thu|fri|sat|sun"},
|
||||
{"name": "rvs_connected", "type": "bool", "desc": "True wenn RVS-Verbindung steht"},
|
||||
{"name": "memory_count", "type": "number", "desc": "Anzahl Memories in der Vector-DB"},
|
||||
]
|
||||
|
||||
|
||||
# ─── Sicherer Condition-Parser ──────────────────────────────────────
|
||||
|
||||
_ALLOWED_NODES = (
|
||||
ast.Expression, ast.BoolOp, ast.UnaryOp, ast.Compare,
|
||||
ast.Name, ast.Constant, ast.Load,
|
||||
ast.And, ast.Or, ast.Not,
|
||||
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
||||
)
|
||||
|
||||
|
||||
def parse_condition(expr: str) -> ast.Expression:
|
||||
"""Parst einen Condition-Ausdruck und validiert ihn gegen das Safe-Subset.
|
||||
Wirft ValueError bei verbotenen Konstrukten."""
|
||||
expr = (expr or "").strip()
|
||||
if not expr:
|
||||
raise ValueError("Leere Condition")
|
||||
if len(expr) > 500:
|
||||
raise ValueError("Condition zu lang (>500 Zeichen)")
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Condition Syntax-Fehler: {e}")
|
||||
# Whitelist-Walk
|
||||
allowed_names = {v["name"] for v in describe_variables()}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, _ALLOWED_NODES):
|
||||
raise ValueError(f"Verbotener Ausdruck: {type(node).__name__}")
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in allowed_names and node.id not in ("True", "False"):
|
||||
raise ValueError(f"Unbekannte Variable: {node.id}")
|
||||
if isinstance(node, ast.Constant):
|
||||
if not isinstance(node.value, (int, float, str, bool)) and node.value is not None:
|
||||
raise ValueError(f"Verbotener Konstant-Typ: {type(node.value).__name__}")
|
||||
return tree
|
||||
|
||||
|
||||
def evaluate(expr: str, variables: dict[str, Any] | None = None) -> bool:
|
||||
"""Evaluiert die Condition gegen die aktuellen Variablen.
|
||||
Returns bool. Bei Fehler in Variablen → False (defensiv)."""
|
||||
tree = parse_condition(expr)
|
||||
vars_ = variables if variables is not None else collect_variables()
|
||||
code = compile(tree, "<condition>", "eval")
|
||||
# Globals leer, locals nur die erlaubten Variablen → kein Builtin-Zugriff
|
||||
try:
|
||||
result = eval(code, {"__builtins__": {}}, vars_)
|
||||
except Exception as e:
|
||||
logger.warning("Condition '%s' eval-Fehler: %s", expr, e)
|
||||
return False
|
||||
return bool(result)
|
||||
Reference in New Issue
Block a user