From 07c761fc72b8785c76664ccf86c7358eddd2b9ef Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 12 May 2026 00:52:13 +0200 Subject: [PATCH] feat(brain): GPS-Variablen + near()-Helper + erweiterte Condition-Vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARIA kann jetzt GPS-basierte Watcher-Trigger anlegen (Blitzer-Warner-Use-Case), plus erweiterte Time-, System- und Activity-Variablen. bridge/aria_bridge.py _persist_state() schreibt atomar nach /shared/state/.json. Bei jedem chat- und audio-Event: - location → /shared/state/location.json {lat, lon, ts_unix} - last_user_ts → /shared/state/activity.json Brain-Watcher lesen das fuer die GPS- und Activity-Variablen. aria-brain/watcher.py — komplett ueberarbeitet Neue Variablen-Sets: GPS: current_lat, current_lon, location_age_sec (-1 = nie gesehen) Zeit (+): minute_of_hour, day_of_month, month, year, is_weekend, unix_timestamp System: ram_free_mb (MemAvailable), cpu_load_1min (loadavg) Activity: last_user_message_ago_sec Memory: pinned_count (zusaetzlich zu memory_count) Neue Funktion fuer Conditions: near(lat, lon, radius_m) Haversine-Distanz von current_lat/lon zum Punkt. False wenn keine Position bekannt. Parser-Erweiterung: ast.Call jetzt erlaubt, ABER nur fuer direkte Funktionsnamen aus der Whitelist (_ALLOWED_FUNCTIONS = {"near"}). Keine Attribute-Access, keine Keywords, Args nur Constants/Names/UnaryOp. Selbsttest blockt korrekt: __import__("os")... → "Funktionsaufruf nur ueber direkten Namen" memory_count.__class__ → "Verbotener Ausdruck: Attribute" (lambda: 1)() → "Funktionsaufruf nur ueber direkten Namen" aria-brain/main.py /triggers/conditions liefert jetzt zusaetzlich {functions:[...]} mit Signaturen + Beschreibungen. current-Snapshot filtert callable() raus damit JSON serialisierbar bleibt. aria-brain/prompts.py + agent.py build_triggers_section bekommt condition_funcs als 4tes Argument und listet die im System-Prompt unter "Verfuegbare Funktionen". Operatoren- Hinweis ergaenzt mit Beispielen + Regeln (keine Variablen in Funktions- Args, keine Schachtelung). diagnostic/index.html Trigger-Create-Modal: Variablen-Info-Block zeigt jetzt sowohl Variablen (mit aktuellen Werten) als auch Funktionen (Signatur + Beschreibung). Co-Authored-By: Claude Opus 4.7 (1M context) --- aria-brain/agent.py | 4 +- aria-brain/main.py | 9 +- aria-brain/prompts.py | 24 +++-- aria-brain/watcher.py | 208 +++++++++++++++++++++++++++++++++++++----- bridge/aria_bridge.py | 45 +++++++++ diagnostic/index.html | 12 ++- 6 files changed, 266 insertions(+), 36 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 05e7c9c..c972873 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -264,11 +264,13 @@ class Agent: # Trigger-Liste + Variablen-Info fuer den System-Prompt all_triggers = triggers_mod.list_triggers(active_only=False) condition_vars = watcher_mod.describe_variables() + condition_funcs = watcher_mod.describe_functions() # 5. System-Prompt + Window-Messages system_prompt = build_system_prompt(hot, cold, skills=all_skills, triggers=all_triggers, - condition_vars=condition_vars) + condition_vars=condition_vars, + condition_funcs=condition_funcs) messages = [ProxyMessage(role="system", content=system_prompt)] for t in self.conversation.window(): messages.append(ProxyMessage(role=t.role, content=t.content)) diff --git a/aria-brain/main.py b/aria-brain/main.py index 1d147c1..dbf5943 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -470,10 +470,15 @@ def triggers_list(active_only: bool = False): @app.get("/triggers/conditions") def triggers_conditions(): - """Verfuegbare Variablen fuer Watcher-Conditions (mit aktuellen Werten).""" + """Verfuegbare Variablen + Funktionen fuer Watcher-Conditions + (mit aktuellen Werten).""" + current = watcher_mod.collect_variables() + # near() ist ein callable in vars_ — fuer die UI rausfiltern + serializable = {k: v for k, v in current.items() if not callable(v)} return { "variables": watcher_mod.describe_variables(), - "current": watcher_mod.collect_variables(), + "functions": watcher_mod.describe_functions(), + "current": serializable, } diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 3c3d462..d5db6cf 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -115,8 +115,12 @@ def build_skills_section(skills: List[dict]) -> str: return "\n".join(lines) -def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) -> str: - """Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen.""" +def build_triggers_section( + triggers: List[dict], + condition_vars: List[dict], + condition_funcs: List[dict] | None = None, +) -> str: + """Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen + Funktionen.""" lines = ["## Trigger (passive Aufweck-Quellen)"] lines.append("") lines.append("Trigger sind ANDERS als Skills: das System ruft DICH wenn ein Event passiert. " @@ -135,14 +139,21 @@ def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) -> lines.append("### Verfuegbare Condition-Variablen (fuer Watcher)") for v in condition_vars: lines.append(f"- `{v['name']}` ({v['type']}) — {v['desc']}") + if condition_funcs: + lines.append("") + lines.append("### Verfuegbare Funktionen in Conditions") + for fn in condition_funcs: + lines.append(f"- `{fn['signature']}` — {fn['desc']}") lines.append("") lines.append("Operatoren in Conditions: `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`. " - "Beispiel: `disk_free_gb < 5 and hour_of_day >= 8`. " - "String-Werte in Quotes: `day_of_week == \"mon\"`.") + "Beispiele: `disk_free_gb < 5 and hour_of_day >= 8`, " + "`day_of_week == \"mon\"`, `near(53.123, 7.456, 500)`. " + "Funktionen nur mit Konstanten als Argumenten (keine Variablen, " + "keine geschachtelten Funktionen).") lines.append("") lines.append("### Wann welcher Typ?") lines.append("- **Timer** fuer einmalige Erinnerungen mit konkreter Zeit ('in 10min', 'um 14:30').") - lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit).") + lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit, GPS-Naehe).") lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.") return "\n".join(lines) @@ -153,6 +164,7 @@ def build_system_prompt( skills: List[dict] | None = None, triggers: List[dict] | None = None, condition_vars: List[dict] | None = None, + condition_funcs: List[dict] | None = None, ) -> str: """Kompletter System-Prompt: Hot + Cold + Skills + Triggers.""" parts = [build_hot_memory_section(pinned)] @@ -161,7 +173,7 @@ def build_system_prompt( parts.append(build_skills_section(skills)) if condition_vars: parts.append("") - parts.append(build_triggers_section(triggers or [], condition_vars)) + parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs)) if cold: parts.append("") parts.append(build_cold_memory_section(cold)) diff --git a/aria-brain/watcher.py b/aria-brain/watcher.py index 211b515..ce22316 100644 --- a/aria-brain/watcher.py +++ b/aria-brain/watcher.py @@ -1,22 +1,25 @@ """ 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. +Erlaubte Variablen + die EINE Funktion `near(lat, lon, radius_m)` kommen +aus diesem Modul. Condition-Ausdruck ist ein sicheres Subset von Python +(kein eval, kein exec): nur Vergleiche, Boolean-Operatoren, Whitelisted +Funktionen, Variablen aus describe_variables(), Konstanten (Zahl/Bool/Str). 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) + is_weekend and minute_of_hour == 0 + near(53.123, 7.456, 500) + current_lat and location_age_sec < 120 """ from __future__ import annotations import ast +import json import logging +import math import os import shutil import time @@ -26,6 +29,20 @@ from typing import Any logger = logging.getLogger(__name__) +STATE_DIR = Path("/shared/state") + + +# ─── State-Helfer (gemeinsam mit Bridge: /shared/state/*.json) ────── + +def _read_state(name: str) -> dict | None: + f = STATE_DIR / f"{name}.json" + if not f.exists(): + return None + try: + return json.loads(f.read_text(encoding="utf-8")) + except Exception: + return None + # ─── Variablen-Quellen ────────────────────────────────────────────── @@ -50,55 +67,184 @@ def _uptime_sec() -> int: 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 +def _ram_free_mb() -> int: + """Container-RAM: MemAvailable aus /proc/meminfo (kB → MB).""" + try: + with open("/proc/meminfo", "r") as f: + for line in f: + if line.startswith("MemAvailable:"): + return int(line.split()[1]) // 1024 + except Exception: + pass + return 0 + + +def _cpu_load_1min() -> float: + """load avg ueber 1 Minute (linux). Vorsicht: das ist die HOST-load, + nicht container-spezifisch.""" + try: + with open("/proc/loadavg", "r") as f: + return float(f.read().split()[0]) + except Exception: + return 0.0 _DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +def _gps_state() -> dict[str, Any]: + """Letzte bekannte Position aus /shared/state/location.json. + Returns dict mit current_lat, current_lon (oder None), location_age_sec.""" + data = _read_state("location") or {} + now = int(time.time()) + age = -1 + lat = data.get("lat") + lon = data.get("lon") + ts = data.get("ts_unix") + if isinstance(ts, (int, float)): + age = int(now - ts) + return { + "current_lat": float(lat) if isinstance(lat, (int, float)) else None, + "current_lon": float(lon) if isinstance(lon, (int, float)) else None, + "location_age_sec": age, + } + + +def _user_activity_age() -> int: + """Sekunden seit letzter User-Aktion (Chat oder Voice). -1 wenn nie.""" + data = _read_state("activity") or {} + ts = data.get("last_user_ts") + if not isinstance(ts, (int, float)): + return -1 + return int(time.time() - ts) + + def collect_variables() -> dict[str, Any]: - """Liefert aktuellen Snapshot aller Built-in-Variablen.""" + """Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.""" 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) + gps = _gps_state() + + # Memory-Counts aus der Vector-DB (lazy import, sonst zirkulaer) memory_count = 0 + pinned_count = 0 try: from main import store # type: ignore s = store() memory_count = s.count() + try: + pinned_count = len(s.list_pinned()) + except Exception: + pass except Exception: pass - return { + vars_: dict[str, Any] = { + # Disk + System "disk_free_gb": round(free_gb, 2), "disk_free_pct": round(free_pct, 1), + "ram_free_mb": _ram_free_mb(), + "cpu_load_1min": round(_cpu_load_1min(), 2), "uptime_sec": _uptime_sec(), + + # Zeit "hour_of_day": now.hour, + "minute_of_hour": now.minute, + "day_of_month": now.day, + "month": now.month, + "year": now.year, "day_of_week": _DAYS[now.weekday()], - "rvs_connected": _rvs_connected(), - "memory_count": memory_count, + "is_weekend": now.weekday() >= 5, + "unix_timestamp": int(time.time()), + + # GPS + "current_lat": gps["current_lat"], + "current_lon": gps["current_lon"], + "location_age_sec": gps["location_age_sec"], + + # Activity + "last_user_message_ago_sec": _user_activity_age(), + + # Memory + "memory_count": memory_count, + "pinned_count": pinned_count, + + # rvs_connected: kann Brain noch nicht zuverlaessig feststellen + # (Bridge muesste eigenen Heartbeat-State schreiben — kommt spaeter) + "rvs_connected": False, } + # Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt. + # Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht. + def _near(lat: float, lon: float, radius_m: float) -> bool: + """Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.""" + cur_lat = vars_.get("current_lat") + cur_lon = vars_.get("current_lon") + if cur_lat is None or cur_lon is None: + return False + try: + R = 6371000.0 + phi1 = math.radians(float(cur_lat)) + phi2 = math.radians(float(lat)) + dphi = math.radians(float(lat) - float(cur_lat)) + dlam = math.radians(float(lon) - float(cur_lon)) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + distance = 2 * R * math.asin(math.sqrt(a)) + return distance < float(radius_m) + except Exception: + return False + + vars_["near"] = _near + return vars_ + def describe_variables() -> list[dict]: - """Liste der verfuegbaren Variablen + Beschreibung — fuer System-Prompt + UI.""" + """Beschreibung — fuer System-Prompt + UI.""" return [ + # Disk / System {"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": "ram_free_mb", "type": "number", "desc": "freier RAM im Brain-Container (MB)"}, + {"name": "cpu_load_1min", "type": "number", "desc": "Load-Avg 1min (Host)"}, {"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"}, + # Zeit {"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"}, + {"name": "minute_of_hour", "type": "number", "desc": "0..59"}, + {"name": "day_of_month", "type": "number", "desc": "1..31"}, + {"name": "month", "type": "number", "desc": "1..12"}, + {"name": "year", "type": "number", "desc": "z.B. 2026"}, {"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"}, + {"name": "is_weekend", "type": "bool", "desc": "True wenn Samstag oder Sonntag"}, + {"name": "unix_timestamp", "type": "number", "desc": "Sekunden seit Epoche (UTC)"}, + # GPS + {"name": "current_lat", "type": "number", "desc": "letzte bekannte Breitengrad (oder None)"}, + {"name": "current_lon", "type": "number", "desc": "letzte bekannte Laengengrad (oder None)"}, + {"name": "location_age_sec", "type": "number", "desc": "Sekunden seit letzter Position (-1 = nie)"}, + # Activity + {"name": "last_user_message_ago_sec", "type": "number", + "desc": "Sekunden seit letztem User-Input (-1 = nie)"}, + # Memory + {"name": "memory_count", "type": "number", "desc": "Anzahl Memories total"}, + {"name": "pinned_count", "type": "number", "desc": "Anzahl pinned (Hot Memory)"}, + {"name": "rvs_connected", "type": "bool", "desc": "RVS-Verbindung (z.Zt. immer False)"}, ] +def describe_functions() -> list[dict]: + """Whitelisted Funktionen fuer Conditions.""" + return [ + { + "name": "near", + "signature": "near(lat, lon, radius_m)", + "desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern " + "vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.", + }, + ] + + +_ALLOWED_FUNCTIONS = {f["name"] for f in describe_functions()} + + # ─── Sicherer Condition-Parser ────────────────────────────────────── _ALLOWED_NODES = ( @@ -106,6 +252,7 @@ _ALLOWED_NODES = ( ast.Name, ast.Constant, ast.Load, ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, + ast.Call, ) @@ -121,13 +268,26 @@ def parse_condition(expr: str) -> ast.Expression: 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.Call): + # Nur direkter Funktionsname, kein attribute-access (foo.bar()) + if not isinstance(node.func, ast.Name): + raise ValueError("Funktionsaufruf nur ueber direkten Namen erlaubt") + if node.func.id not in _ALLOWED_FUNCTIONS: + raise ValueError(f"Verbotene Funktion: {node.func.id}") + # Args muessen Constants oder einzelne Names sein + for a in node.args: + if not isinstance(a, (ast.Constant, ast.Name, ast.UnaryOp)): + raise ValueError(f"Argument-Typ in {node.func.id}() nicht erlaubt") + if node.keywords: + raise ValueError("Keyword-Argumente in Funktionen nicht erlaubt") if isinstance(node, ast.Name): - if node.id not in allowed_names and node.id not in ("True", "False"): + if (node.id not in allowed_names + and node.id not in _ALLOWED_FUNCTIONS + 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: @@ -141,7 +301,7 @@ def evaluate(expr: str, variables: dict[str, Any] | None = None) -> bool: tree = parse_condition(expr) vars_ = variables if variables is not None else collect_variables() code = compile(tree, "", "eval") - # Globals leer, locals nur die erlaubten Variablen → kein Builtin-Zugriff + # Globals leer, locals enthalten Variablen + near()-Funktion → kein Builtin-Zugriff try: result = eval(code, {"__builtins__": {}}, vars_) except Exception as e: diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 2ce159b..4f99eb4 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -21,6 +21,7 @@ import os import re import signal import ssl +import time import sys import tempfile import uuid @@ -919,6 +920,44 @@ class ARIABridge: except Exception as e: logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e) + def _persist_state(self, key: str, data: dict) -> None: + """Atomic-Write in /shared/state/.json — fuer Brain-Watcher. + Wird genutzt fuer location + activity-Tracking.""" + try: + import time as _time + data = dict(data) + data["ts_unix"] = int(_time.time()) + Path("/shared/state").mkdir(parents=True, exist_ok=True) + target = Path(f"/shared/state/{key}.json") + tmp = target.with_suffix(".tmp") + tmp.write_text(json.dumps(data), encoding="utf-8") + tmp.replace(target) + except Exception as e: + logger.warning("[state] %s schreiben fehlgeschlagen: %s", key, e) + + def _persist_location(self, location: Optional[dict]) -> None: + """Speichert die letzte bekannte GPS-Position fuer Watcher. + Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende + Koordinaten werden ignoriert.""" + if not isinstance(location, dict): + return + try: + lat = location.get("lat") + lon = location.get("lon") or location.get("lng") + if lat is None or lon is None: + return + self._persist_state("location", { + "lat": float(lat), + "lon": float(lon), + }) + except Exception: + pass + + def _persist_user_activity(self) -> None: + """Markiert dass der User gerade etwas gemacht hat (Chat/Voice). + Watcher: last_user_message_ago_sec basiert darauf.""" + self._persist_state("activity", {"last_user_ts": int(time.time())}) + def _append_chat_backup(self, entry: dict) -> None: """Schreibt eine Zeile in /shared/config/chat_backup.jsonl. Wird von Diagnostic + App als History-Quelle gelesen. @@ -1479,6 +1518,9 @@ class ARIABridge: if text: interrupted = bool(payload.get("interrupted", False)) location = payload.get("location") or None + # State persist fuer Brain-Watcher (current_lat, ..., last_user_ts) + self._persist_location(location) + self._persist_user_activity() # Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig # gesendet), mergen wir sie zu einer einzigen Anfrage statt # zwei separater send_to_core-Calls. @@ -1961,6 +2003,9 @@ class ARIABridge: interrupted = bool(payload.get("interrupted", False)) audio_request_id = payload.get("audioRequestId", "") or "" location = payload.get("location") or None + # State persist fuer Brain-Watcher (current_lat etc.) + self._persist_location(location) + self._persist_user_activity() logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s", mime_type, duration_ms, len(audio_b64) // 1365, " [BARGE-IN]" if interrupted else "", diff --git a/diagnostic/index.html b/diagnostic/index.html index 4c25ef6..de20a27 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -2886,15 +2886,21 @@ document.getElementById('trigger-message').value = ''; document.getElementById('trigger-modal-error').style.display = 'none'; onTriggerTypeChange(); - // Variablen-Hinweis laden + // Variablen + Funktionen-Hinweis laden try { const r = await fetch('/api/brain/triggers/conditions'); const d = await r.json(); const info = document.getElementById('trigger-vars-info'); if (info) { - info.innerHTML = 'Variablen: ' + (d.variables || []).map(v => - `${escapeHtml(v.name)} = ${escapeHtml(String(d.current[v.name]))} (${escapeHtml(v.desc)})` + const vars = (d.variables || []).map(v => + `${escapeHtml(v.name)}=${escapeHtml(String(d.current[v.name]))} (${escapeHtml(v.desc)})` ).join(' · '); + const fns = (d.functions || []).map(f => + `${escapeHtml(f.signature)} — ${escapeHtml(f.desc)}` + ).join('
'); + info.innerHTML = + 'Variablen: ' + vars + + (fns ? '

Funktionen:
' + fns : ''); } } catch {} document.getElementById('trigger-modal').classList.add('open');