Compare commits

..

11 Commits

Author SHA1 Message Date
duffyduck c3fefc60c0 release: bump version to 0.1.2.6 2026-05-12 01:08:35 +02:00
duffyduck 7107ce4fdd docs: README + issue — Triggers, GPS-Tracking, Bug-Fixes nachgezogen
README: Trigger-Tab in Diagnostic-Tabs-Listen, App-Feature
"GPS-Tracking (kontinuierlich)". issue.md: neuer Block
"Triggers-System (Phase B Punkt 5)" + 8 frische Bug-Fixes
oben (agent_activity-Haenger, Such-Scroll, STT-Bubble-Timing,
Diagnostic Brain-Antworten, Brain-Card-Live-Status, App-Chat-Sync,
Konversation-Reset-Doppelschlag, OpenClaw-Ghost-IDs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:07:30 +02:00
duffyduck fa47068d6d feat(gps): kontinuierliches GPS-Tracking — Blitzer-Warner-Pipeline komplett
ARIA kann jetzt GPS-Watcher mit near() effektiv nutzen: die App liefert
kontinuierliche Position, Brain wertet sie in den Background-Triggers aus.

rvs/server.js
  ALLOWED_TYPES: location_update (App→Bridge) + location_tracking (Brain→App).

bridge/aria_bridge.py
  location_update Handler: persistiert {lat, lon} via _persist_location in
  /shared/state/location.json — selber Pfad wie chat/audio-events, aber als
  eigenes Event ohne Chat-Overhead.

aria-brain/agent.py
  Neues Meta-Tool request_location_tracking(on, reason). Dispatcher fuegt
  {type: "location_tracking", on, reason} zu _pending_events hinzu →
  Bridge forwarded als RVS-Message zur App.

aria-brain/prompts.py
  Trigger-Section bekam neuen Block "GPS-Watcher mit near()": ARIA wird
  angewiesen request_location_tracking(on=true) zu rufen wenn sie einen
  near()-Watcher anlegt, und wieder false beim Loeschen des letzten.

android/src/services/gpsTracking.ts (NEU)
  Singleton-Service. start(reason) → Geolocation.watchPosition mit
  distanceFilter 30m + interval 15s, sendet location_update an RVS.
  stop(reason) → clearWatch. Persistiert Status in 'aria_gps_tracking',
  restoreFromStorage() beim Settings-Mount. Permission-Request fuer
  ACCESS_FINE_LOCATION + Toast-Benachrichtigung bei An/Aus.

android/src/screens/SettingsScreen.tsx
  Neuer Switch im "Standort"-Block: "GPS-Tracking (kontinuierlich)" mit
  Hinweis-Text. Subscribe auf gpsTrackingService.onChange damit Toggle
  reflektiert wenn ARIA das per Tool umschaltet.
  RVS-Handler: location_tracking → gpsTrackingService.start/stop mit
  Reason aus Brain-Tool.

Ablauf Stefan→ARIA→Blitzer:
  1. Stefan: "Warn mich vor Blitzern auf Route nach Rhauderfehn"
  2. ARIA: skill_create("blitzer-warner") falls noch nicht da
  3. ARIA: run_blitzer-warner → Liste {lat,lon,name}
  4. ARIA: pro Eintrag trigger_watcher mit near(lat,lon,500)
  5. ARIA: request_location_tracking(on=true, reason="Blitzer-Warner aktiv")
  6. App: GPS-Tracking startet, sendet alle 15s location_update
  7. Bridge: /shared/state/location.json wird aktuell gehalten
  8. Brain-Background-Loop: alle 30s near()-Check pro Trigger
  9. Bei Erfolg: ARIA spricht "Blitzer A31 km 12 in 500m"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:02:05 +02:00
duffyduck 07c761fc72 feat(brain): GPS-Variablen + near()-Helper + erweiterte Condition-Vars
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/<key>.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) <noreply@anthropic.com>
2026-05-12 00:52:13 +02:00
duffyduck 6821eaaa38 release: bump version to 0.1.2.5 2026-05-12 00:40:25 +02:00
duffyduck 31aa86a2a9 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>
2026-05-12 00:38:58 +02:00
duffyduck 87cb687610 fix(diagnostic): ARIA-Textantworten landen jetzt im Chat (Gateway-Dedup raus)
Symptom: Diagnostic-Chat zeigt nur ARIA-Dateien (file_from_aria), Text-
Antworten kamen nicht an. STT-Eintraege + User-Messages waren sichtbar.

Ursache: Im rvs_chat-Handler stand
  if (sender === 'aria') return;
Die alte Begruendung war "ARIA-Antworten kommen schon via Gateway (chat:final)".
Das galt zu OpenClaw-Zeit, wo Diagnostic eine direkte WS zum aria-core hatte.
Gateway ist seit dem Abriss weg, ARIA-Antworten kommen jetzt ausschliesslich
via RVS → der return blockte sie still.

Fix: chatType + label je nach sender:
  - aria  → received-Bubble, Label "ARIA"
  - stt   → sent-Bubble, Label "🎤 Spracheingabe" (wie vorher)
  - sonst → sent-Bubble, Label "via RVS (<sender>)"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:21:10 +02:00
duffyduck eb4059a887 fix: 3 Bugs — agent_activity haengt, Such-Scroll, STT-Bubble-Timing
Bug 1: "ARIA denkt..." in der App bleibt stehen
  _process_core_response setzte am Ende kein idle — die alten Aufrufe waren
  in der OpenClaw-WS-Loop, in der Brain-HTTP-Variante fehlten sie. Plus
  send_to_core schickte agent_activity direkt via _send_to_rvs ohne den
  _last_activity_state-Cache zu pflegen → _emit_activity("idle") wurde
  spaeter dedupliziert.
  Fix:
    - _emit_activity statt direktem _send_to_rvs fuer thinking
    - _emit_activity("idle") am Ende von _process_core_response
    - _last_chat_final_at bewusst NICHT setzen — die 3s-Cooldown war fuer
      trailing OpenClaw-Events, wuerde bei Voice die naechste thinking-Welle
      unterdruecken

Bug 2: App Chat-Suche scrollt nicht zur Stelle
  scrollToIndex wurde zu fruh aufgerufen (Layout noch nicht fertig) und
  viewPosition: 0.4 in inverted-FlatList war ungenau.
  Fix:
    - requestAnimationFrame um den Scroll-Aufruf
    - viewPosition: 0.5 (mittig)
    - onScrollToIndexFailed: erst grob scrollen via averageItemLength,
      dann nach 250ms praeziser nachfassen

Bug 3: Voice-Bubble bekommt STT-Text erst mit ARIA-Antwort
  _process_app_audio rief erst send_to_core (blockt synchron auf Brain,
  kann 300s dauern), DANN STT-Broadcast. App sah den eigenen Text erst
  wenn ARIA fertig war.
  Fix: Reihenfolge getauscht — STT-Broadcast zuerst, dann send_to_core.
  Voice-Bubble bekommt jetzt den erkannten Text sofort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:17:10 +02:00
duffyduck 415706036b release: bump version to 0.1.2.4 2026-05-11 23:57:16 +02:00
duffyduck e2dd47255e docs: README + issue.md — App-Chat-Sync-Verhalten praezisiert
App-Chat-Sync ist seit Commit 3497aa2 "Server is Source of Truth" — bei
jedem Reconnect KOMPLETTER Server-Stand statt incremental. Doku angepasst:

  - App leert sich wenn Server leer ist (z.B. nach "Konversation zuruecksetzen")
  - Lokal-only Bubbles bleiben erhalten (Skill-Notifications, Voice ohne STT)
  - Bridge schreibt chat_backup.jsonl pro Turn — als Server-Backing-Store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:56:14 +02:00
duffyduck 3497aa23f8 fix(app): kompletter Server-Sync bei Reconnect — Server ist Source of Truth
Symptom: Diagnostic-Server hat leere Chat-History (z.B. nach "Konversation
zuruecksetzen" oder Wipe), App zeigt aber weiterhin ihren alten lokalen
Stand. Wer das Wipe-Event verpasst hat (App offline), bleibt veraltet.

Ursache: App schickte beim Reconnect chat_history_request {since: lastSync}
und ignorierte leere Antworten. Wenn der Server ueberhaupt nichts mehr hat
liefert er korrekt [] zurueck — App behielt aber lokalen State.

Fix:
  - App schickt jetzt {since: 0, limit: 200} → KOMPLETTER Server-Stand
  - Handler ersetzt die persistierte Chat-History mit dem Server-Stand
    (statt zu mergen)
  - Lokal-only Bubbles bleiben erhalten:
      * Skill-Created-Notifications (skillCreated gesetzt)
      * Laufende Sprachnachrichten ohne STT-Result (audioRequestId gesetzt
        und text leer/Placeholder)
  - Wenn Server leer: lastSync ebenfalls geloescht (sauberer Restart-State)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:55:25 +02:00
16 changed files with 1792 additions and 66 deletions
+6 -2
View File
@@ -195,11 +195,12 @@ Bestehendes Token nochmal als QR anzeigen: `./generate-token.sh show`
http://<VM-IP>:3001
```
Die Diagnostic-UI hat fünf Top-Tabs:
Die Diagnostic-UI hat sechs Top-Tabs:
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), near(lat, lon, m) als Condition-Funktion
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
@@ -314,6 +315,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons () ueberall mit Modal-Erklaerung.
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`).
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
@@ -356,6 +358,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- QR-Code Scanner fuer Token-Pairing
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-`![alt](url)`-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
@@ -859,8 +862,9 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner)
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10203
versionName "0.1.2.3"
versionCode 10206
versionName "0.1.2.6"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.2.3",
"version": "0.1.2.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+101 -25
View File
@@ -79,6 +79,14 @@ interface ChatMessage {
active: boolean;
setupError?: string;
};
/** Trigger-Created-Bubble: ARIA hat einen neuen Trigger angelegt */
triggerCreated?: {
name: string;
type: 'timer' | 'watcher' | string;
message: string;
fires_at?: string;
condition?: string;
};
}
// --- Konstanten ---
@@ -407,15 +415,17 @@ const ChatScreen: React.FC = () => {
return;
}
// chat_history_response: verpasste Nachrichten nachladen (bei Reconnect)
// chat_history_response: kompletter Server-Stand. App ersetzt ihre
// persistierte Chat-History damit. Lokal-only Bubbles (laufende
// Voice-Aufnahmen ohne STT-Result, Skill-Created-Events ohne
// text) bleiben erhalten — die sind durch fehlendes 'text' oder
// skillCreated/audioRequestId klar als "lokal" erkennbar.
if (message.type === 'chat_history_response') {
const p = (message.payload || {}) as any;
const incoming = (p.messages || []) as Array<any>;
if (!incoming.length) return;
console.log(`[Chat] ${incoming.length} verpasste Nachrichten nachgeladen`);
const toAdd: ChatMessage[] = incoming.map(m => {
console.log(`[Chat] Server-Sync: ${incoming.length} Nachrichten vom Server`);
const fromServer: ChatMessage[] = incoming.map(m => {
const role = m.role === 'user' ? 'user' : 'aria';
// ARIA-File-Marker aus dem Backup als attachments rekonstruieren
const files = Array.isArray(m.files) ? m.files : [];
const attachments = files.map((f: any) => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
@@ -434,12 +444,25 @@ const ChatScreen: React.FC = () => {
});
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
setMessages(prev => {
// Dedup auf ts-basis: nicht erneut adden wenn schon was bei +/- 1s vorhanden
const existingTs = new Set(prev.map(m => m.timestamp));
const newOnes = toAdd.filter(m => !existingTs.has(m.timestamp));
return capMessages([...prev, ...newOnes]);
// Lokal-only Bubbles erkennen + behalten:
// - Skill-Created-Notifications (skillCreated gesetzt)
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
// gesetzt UND text leer/Placeholder)
const localOnly = prev.filter(m =>
m.skillCreated ||
m.triggerCreated ||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
);
// Server-Stand + lokal-only (chronologisch sortiert)
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
return capMessages(merged);
});
if (maxTs > 0) AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
if (maxTs > 0) {
AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
} else {
// Server leer → unsere lastSync auch zuruecksetzen
AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {});
}
return;
}
@@ -462,6 +485,26 @@ const ChatScreen: React.FC = () => {
return;
}
// trigger_created: ARIA hat einen Trigger angelegt → eigene Bubble
if (message.type === 'trigger_created') {
const p = (message.payload || {}) as any;
const triggerMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text: '',
timestamp: Date.now(),
triggerCreated: {
name: String(p.name || '(unbenannt)'),
type: String(p.type || 'timer'),
message: String(p.message || ''),
fires_at: p.fires_at ? String(p.fires_at) : undefined,
condition: p.condition ? String(p.condition) : undefined,
},
};
setMessages(prev => capMessages([...prev, triggerMsg]));
return;
}
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
if (message.type === 'file_deleted') {
const p = (message.payload?.path as string) || '';
@@ -701,14 +744,13 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => {
setConnectionState(state);
// Bei (re)connect: verpasste Chat-Eintraege seit der letzten gesehenen
// Nachricht abholen. lastChatSync wird beim Eingang von Nachrichten
// hochgezaehlt; default 0 = alle (gecappt auf Server-Limit).
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
// war als das passiert ist. since=0 + limit=200 → die letzten 200
// Nachrichten vom Server, oder leeres Array wenn Server leer.
if (state === 'connected') {
AsyncStorage.getItem('aria_chat_last_sync').then(stored => {
const since = stored ? parseInt(stored, 10) || 0 : 0;
rvs.send('chat_history_request' as any, { since, limit: 100 });
}).catch(() => {});
rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
}
});
@@ -907,17 +949,25 @@ const ChatScreen: React.FC = () => {
setSearchIndex(0);
}, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
// damit Layout sicher fertig ist.
useEffect(() => {
if (!searchMatchIds.length) return;
const id = searchMatchIds[searchIndex];
if (!id) return;
// invertedMessages → index in der angezeigten Liste finden
const idx = invertedMessages.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
try {
flatListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.4 });
} catch {}
const tryScroll = () => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch {
// wird von onScrollToIndexFailed nochmal versucht
}
};
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
requestAnimationFrame(tryScroll);
}, [searchIndex, searchMatchIds, invertedMessages]);
const activeSearchId = searchMatchIds[searchIndex] || '';
@@ -1186,6 +1236,28 @@ const ChatScreen: React.FC = () => {
? { borderWidth: 2, borderColor: '#FFD60A' }
: null;
// Spezial-Bubble: ARIA hat einen Trigger angelegt
if (item.triggerCreated) {
const t = item.triggerCreated;
const detailLine = t.type === 'timer'
? `feuert: ${t.fires_at || '?'}`
: `wenn: ${t.condition || '?'}`;
return (
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'⏰ ARIA hat einen Trigger angelegt'}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{t.name}</Text>
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${t.type})`}</Text>
</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2, fontFamily: 'monospace'}}>{detailLine}</Text>
<Text style={{color: '#888', fontSize: 12, marginTop: 2}}>{`"${t.message}"`}</Text>
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Trigger · {time}</Text>
</View>
);
}
// Spezial-Bubble: ARIA hat einen Skill erstellt
if (item.skillCreated) {
const s = item.skillCreated;
@@ -1427,10 +1499,14 @@ const ChatScreen: React.FC = () => {
inverted
data={invertedMessages}
onScrollToIndexFailed={(info) => {
// Bei zu schnellem Aufruf vor Layout: einmal nachfassen
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
// praezise nochmal versuchen.
const offset = info.averageItemLength * info.index;
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
setTimeout(() => {
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.4 }); } catch {}
}, 200);
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
}, 250);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
+46
View File
@@ -51,6 +51,7 @@ import {
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import {
isWakeReadySoundEnabled,
@@ -121,6 +122,7 @@ const SettingsScreen: React.FC = () => {
const [manualPort, setManualPort] = useState('8765');
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -188,6 +190,11 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
gpsTrackingService.restoreFromStorage().catch(() => {});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
@@ -245,6 +252,10 @@ const SettingsScreen: React.FC = () => {
});
// Voice-Liste vom XTTS-Server holen (via RVS)
rvs.send('xtts_list_voices' as any, {});
return () => {
// gpsTrackingService-Listener abmelden (Variable offGps oben definiert)
try { offGps(); } catch {}
};
}, []);
// Speichergroesse berechnen
@@ -407,6 +418,18 @@ const SettingsScreen: React.FC = () => {
}
}
// ARIA bittet um GPS-Tracking An/Aus (Tool request_location_tracking)
if (message.type === ('location_tracking' as any)) {
const p: any = message.payload || {};
const on = !!p.on;
const reason = (p.reason as string) || 'ARIA';
if (on) {
gpsTrackingService.start(reason).catch(() => {});
} else {
gpsTrackingService.stop(reason);
}
}
// Datei-Manager: ZIP-Response (Multi-Download)
if (message.type === ('file_zip_response' as any)) {
const p: any = message.payload || {};
@@ -1004,6 +1027,29 @@ const SettingsScreen: React.FC = () => {
thumbColor={gpsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
{/* GPS-Tracking (kontinuierlich) — fuer near()-Watcher */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS-Tracking (kontinuierlich)</Text>
<Text style={styles.toggleHint}>
Sendet alle ~15s deine Position an ARIA (wenn du dich {'>'}30m bewegt
hast). Nur noetig fuer GPS-basierte Trigger wie Blitzer-Warner
(near()-Conditions). ARIA kann das auch selbst an-/abschalten wenn
sie einen GPS-Watcher anlegt. Akku-Verbrauch erhoeht — bei langer
Fahrt einplanen.
</Text>
</View>
<Switch
value={gpsTracking}
onValueChange={(v) => {
if (v) gpsTrackingService.start('manuell').catch(() => {});
else gpsTrackingService.stop('manuell');
}}
trackColor={{ false: '#2A2A3E', true: '#FF9500' }}
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
+138
View File
@@ -0,0 +1,138 @@
/**
* GPS-Tracking-Service.
*
* Wenn aktiv: pushed alle paar Sekunden die aktuelle Position als
* `location_update {lat, lon}` an den RVS-Server, damit Brain-Watcher
* mit `near()`-Conditions etwas zum Vergleichen haben.
*
* Default: AUS. Wird entweder vom User manuell in Settings angeschaltet
* oder von ARIA via location_tracking-RVS-Message (Brain-Tool
* `request_location_tracking`).
*
* Energie-Schutz: distanceFilter 30m, interval 15s. Echte Fahrt-Updates
* (Geschwindigkeit) kommen sauber durch, stationaer wird kaum gesendet.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
import Geolocation from '@react-native-community/geolocation';
import rvs from './rvs';
type Listener = (active: boolean) => void;
class GpsTrackingService {
private watchId: number | null = null;
private active = false;
private listeners: Set<Listener> = new Set();
// Defensive: nicht zu schnell oeffentlich togglen
private lastChangeAt = 0;
isActive(): boolean {
return this.active;
}
onChange(cb: Listener): () => void {
this.listeners.add(cb);
return () => { this.listeners.delete(cb); };
}
private notify() {
for (const cb of this.listeners) {
try { cb(this.active); } catch {}
}
}
/** Beim App-Start: gespeicherten Zustand wiederherstellen (Default off). */
async restoreFromStorage(): Promise<void> {
try {
const v = await AsyncStorage.getItem('aria_gps_tracking');
if (v === 'true') {
console.log('[gps-track] Restore: war an, starte wieder');
this.start('Beim Start wiederhergestellt');
}
} catch {}
}
private async ensurePermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'GPS-Tracking',
message: 'ARIA braucht laufende Standort-Updates damit GPS-Watcher (Blitzer-Warner, near()) funktionieren.',
buttonPositive: 'Erlauben',
buttonNegative: 'Abbrechen',
},
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch (e) {
console.warn('[gps-track] Permission-Fehler:', e);
return false;
}
}
async start(reason: string = ''): Promise<boolean> {
if (this.active) return true;
const ok = await this.ensurePermission();
if (!ok) {
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
return false;
}
try {
this.watchId = Geolocation.watchPosition(
(pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
rvs.send('location_update' as any, { lat, lon });
},
(err) => {
console.warn('[gps-track] watchPosition error:', err?.code, err?.message);
},
{
enableHighAccuracy: true,
distanceFilter: 30, // erst senden wenn 30m gewandert
interval: 15000, // (Android) gewuenschte Frequenz
fastestInterval: 10000, // (Android) max Frequenz
} as any,
);
this.active = true;
this.lastChangeAt = Date.now();
this.notify();
AsyncStorage.setItem('aria_gps_tracking', 'true').catch(() => {});
ToastAndroid.show(
reason ? `GPS-Tracking aktiv (${reason})` : 'GPS-Tracking aktiv',
ToastAndroid.SHORT,
);
console.log('[gps-track] gestartet', reason ? `(${reason})` : '');
return true;
} catch (e: any) {
console.warn('[gps-track] start fehlgeschlagen:', e?.message);
return false;
}
}
stop(reason: string = ''): void {
if (!this.active) return;
if (this.watchId !== null) {
try { Geolocation.clearWatch(this.watchId); } catch {}
this.watchId = null;
}
this.active = false;
this.lastChangeAt = Date.now();
this.notify();
AsyncStorage.setItem('aria_gps_tracking', 'false').catch(() => {});
ToastAndroid.show(
reason ? `GPS-Tracking aus (${reason})` : 'GPS-Tracking aus',
ToastAndroid.SHORT,
);
console.log('[gps-track] gestoppt', reason ? `(${reason})` : '');
}
async toggle(reason: string = ''): Promise<void> {
if (this.active) this.stop(reason);
else await this.start(reason);
}
}
export default new GpsTrackingService();
+173 -1
View File
@@ -25,6 +25,8 @@ from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt
from proxy_client import ProxyClient, Message as ProxyMessage
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
logger = logging.getLogger(__name__)
@@ -90,6 +92,111 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "trigger_timer",
"description": (
"Lege einen Timer-Trigger an — feuert EINMALIG zum angegebenen Zeitpunkt "
"und ruft dich selbst auf (Push-Nachricht an Stefan). "
"Use-Case: 'erinnere mich in 10min', 'sag mir um 14:30 Bescheid'."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"},
"fires_at": {
"type": "string",
"description": (
"Absoluter ISO-Timestamp UTC, z.B. '2026-05-12T14:30:00Z'. "
"Berechne aus relativer Angabe ('in 10min') selbst — die "
"aktuelle Zeit findest du im System-Prompt nicht, also nutze "
"Bash: `date -u -d '+10 minutes' --iso-8601=seconds`."
),
},
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
},
"required": ["name", "fires_at", "message"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_watcher",
"description": (
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kurzer Name"},
"condition": {
"type": "string",
"description": (
"Boolescher Ausdruck mit den erlaubten Variablen, z.B. "
"'disk_free_gb < 5', 'hour_of_day == 8 and day_of_week == \"mon\"'. "
"Operatoren: < > <= >= == != and or not"
),
},
"message": {"type": "string", "description": "Was soll bei Erfuellung gesagt werden"},
"check_interval_sec": {
"type": "integer",
"description": "Wie oft Condition pruefen (Default 300 = alle 5min, min 30)",
},
"throttle_sec": {
"type": "integer",
"description": "Mindestabstand zwischen 2 Feuerungen (Default 3600 = max 1x/h)",
},
},
"required": ["name", "condition", "message"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_cancel",
"description": "Loescht einen Trigger (Timer abbrechen oder Watcher entfernen).",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_list",
"description": "Zeigt alle Trigger (active + inaktiv). Selten noetig — Stefan sieht sie im Diagnostic.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "request_location_tracking",
"description": (
"Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu "
"deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen "
"GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App "
"veraltete Position und der Watcher feuert nie. Auch wieder ausschalten "
"wenn der letzte GPS-Watcher geloescht wurde."
),
"parameters": {
"type": "object",
"properties": {
"on": {"type": "boolean", "description": "true = Tracking an, false = aus"},
"reason": {"type": "string", "description": "Kurzer Grund (wird in App-Notification angezeigt)"},
},
"required": ["on"],
},
},
},
]
@@ -175,8 +282,16 @@ class Agent:
active_skills = [s for s in all_skills if s.get("active", True)]
tools = list(META_TOOLS) + [_skill_to_tool(s) for s in active_skills]
# 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)
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
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))
@@ -273,6 +388,63 @@ class Agent:
if err:
out += f"\nstderr:\n{err}"
return out
if name == "trigger_timer":
t = triggers_mod.create_timer(
name=arguments["name"],
fires_at_iso=arguments["fires_at"],
message=arguments["message"],
author="aria",
)
self._pending_events.append({
"type": "trigger_created",
"trigger": {"name": t["name"], "type": "timer",
"fires_at": t["fires_at"], "message": t["message"]},
})
return f"OK — Timer '{t['name']}' angelegt, feuert um {t['fires_at']}."
if name == "trigger_watcher":
t = triggers_mod.create_watcher(
name=arguments["name"],
condition=arguments["condition"],
message=arguments["message"],
check_interval_sec=int(arguments.get("check_interval_sec", 300)),
throttle_sec=int(arguments.get("throttle_sec", 3600)),
author="aria",
)
self._pending_events.append({
"type": "trigger_created",
"trigger": {"name": t["name"], "type": "watcher",
"condition": t["condition"], "message": t["message"]},
})
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
if name == "trigger_cancel":
try:
triggers_mod.delete(arguments["name"])
return f"OK — Trigger '{arguments['name']}' geloescht."
except ValueError as e:
return f"FEHLER: {e}"
if name == "request_location_tracking":
on = bool(arguments.get("on", False))
reason = (arguments.get("reason") or "").strip()
self._pending_events.append({
"type": "location_tracking",
"on": on,
"reason": reason,
})
return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten."
if name == "trigger_list":
items = triggers_mod.list_triggers(active_only=False)
if not items:
return "(keine Trigger vorhanden)"
lines = []
for t in items:
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
if t["type"] == "timer":
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')}\"{t.get('message','')[:50]}\"")
elif t["type"] == "watcher":
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+169
View File
@@ -0,0 +1,169 @@
"""
Background-Loop fuer Triggers.
Laeuft alle TICK_SEC Sekunden in einem asyncio Task, geht ueber alle
active Triggers und entscheidet ob sie feuern muessen.
Feuern bedeutet:
1. Trigger-Manifest update (fire_count++, last_fired_at, ggf. deaktivieren)
2. Log-Eintrag schreiben
3. agent.chat() mit einem system-Praefix aufrufen (NICHT als 'user'!)
→ ARIA bekommt das wie eine Push-Nachricht und kann antworten
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import triggers as triggers_mod
import watcher as watcher_mod
logger = logging.getLogger(__name__)
TICK_SEC = 30
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(s: str) -> Optional[datetime]:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def _should_fire(trigger: dict, vars_: dict, now: datetime) -> bool:
if not trigger.get("active", True):
return False
t = trigger.get("type", "")
if t == "timer":
fires_at = _parse_iso(trigger.get("fires_at", ""))
if not fires_at:
return False
if fires_at.tzinfo is None:
fires_at = fires_at.replace(tzinfo=timezone.utc)
return now >= fires_at
if t == "watcher":
# Check-Interval respektieren (sonst pollen wir zu hektisch)
check_interval = int(trigger.get("check_interval_sec", 300))
last_checked = _parse_iso(trigger.get("last_checked_at", ""))
if last_checked:
if last_checked.tzinfo is None:
last_checked = last_checked.replace(tzinfo=timezone.utc)
if (now - last_checked).total_seconds() < check_interval:
return False
# Throttle: erst feuern wenn last_fired lange genug her ist
last_fired = _parse_iso(trigger.get("last_fired_at", ""))
throttle = int(trigger.get("throttle_sec", 3600))
if last_fired:
if last_fired.tzinfo is None:
last_fired = last_fired.replace(tzinfo=timezone.utc)
if (now - last_fired).total_seconds() < throttle:
return False
# Condition pruefen
cond = (trigger.get("condition") or "").strip()
if not cond:
return False
try:
return watcher_mod.evaluate(cond, vars_)
except Exception as e:
logger.warning("Trigger %s: Condition '%s' fehlerhaft: %s",
trigger.get("name"), cond, e)
return False
if t == "cron":
# TODO: später, wenn jemand Bock auf Cron-Parser hat
return False
return False
async def _fire(trigger: dict, agent_factory) -> None:
"""Ruft ARIA mit einer System-Praefix-Nachricht auf."""
name = trigger.get("name", "?")
message = trigger.get("message") or "(ohne Nachricht)"
ttype = trigger.get("type", "?")
# Manifest updaten
try:
triggers_mod.mark_fired(name)
except Exception as e:
logger.warning("mark_fired %s: %s", name, e)
# Log
triggers_mod.append_log(name, {"event": "fired", "type": ttype, "message": message})
# System-Nachricht an ARIA: nicht als User, sondern als Hinweis
prompt = (
f"[Trigger ausgelöst: '{name}', Typ: {ttype}] "
f"Geplante Nachricht: \"{message}\". "
f"Sage Stefan jetzt diese Information, in deinem Stil. "
f"Wenn der Trigger ein Watcher war (Bedingung wurde erfuellt), "
f"erwaehne kurz worum es geht. Antworte direkt, keine Rueckfrage."
)
try:
agent = agent_factory()
reply = agent.chat(prompt, source="trigger")
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
try:
all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e:
logger.warning("triggers.list: %s", e)
return
if not all_triggers:
return
now = datetime.now(timezone.utc)
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
try:
vars_ = watcher_mod.collect_variables()
except Exception as e:
logger.warning("collect_variables: %s", e)
vars_ = {}
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
# damit der Check-Interval respektiert wird)
for t in all_triggers:
if t.get("type") == "watcher":
try:
t["last_checked_at"] = _now_iso()
triggers_mod.write(t["name"], t)
except Exception:
pass
for trigger in all_triggers:
try:
if _should_fire(trigger, vars_, now):
# Feuern als eigener Task — wenn ARIA langsam antwortet,
# darf der naechste Tick nicht blockieren
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
async def run_loop(agent_factory) -> None:
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
while True:
try:
await _tick(agent_factory)
except Exception as e:
logger.exception("Tick-Fehler: %s", e)
await asyncio.sleep(TICK_SEC)
+125 -1
View File
@@ -20,6 +20,9 @@ import logging
import os
from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import Response
from pydantic import BaseModel, Field
@@ -30,6 +33,9 @@ from proxy_client import ProxyClient
from agent import Agent
import skills as skills_mod
import metrics as metrics_mod
import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -37,7 +43,23 @@ logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
app = FastAPI(title="ARIA Brain", version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Lifespan: Trigger-Loop gestoppt")
app = FastAPI(title="ARIA Brain", version="0.1.0", lifespan=lifespan)
_embedder: Optional[Embedder] = None
_store: Optional[VectorStore] = None
@@ -414,6 +436,108 @@ def metrics_calls():
return metrics_mod.stats()
# ─── Triggers (passive Aufweck-Quellen) ─────────────────────────────
class TriggerTimerBody(BaseModel):
name: str
fires_at: str # ISO timestamp
message: str
author: str = "stefan"
class TriggerWatcherBody(BaseModel):
name: str
condition: str
message: str
check_interval_sec: int = 300
throttle_sec: int = 3600
author: str = "stefan"
class TriggerPatch(BaseModel):
active: bool | None = None
message: str | None = None
condition: str | None = None
throttle_sec: int | None = None
check_interval_sec: int | None = None
fires_at: str | None = None
@app.get("/triggers/list")
def triggers_list(active_only: bool = False):
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
@app.get("/triggers/conditions")
def triggers_conditions():
"""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(),
"functions": watcher_mod.describe_functions(),
"current": serializable,
}
@app.get("/triggers/{name}")
def triggers_get(name: str):
t = triggers_mod.read(name)
if t is None:
raise HTTPException(404, f"Trigger '{name}' nicht gefunden")
return t
@app.get("/triggers/{name}/logs")
def triggers_get_logs(name: str, limit: int = 50):
return {"logs": triggers_mod.list_logs(name, limit=limit)}
@app.post("/triggers/timer")
def triggers_create_timer(body: TriggerTimerBody):
try:
return triggers_mod.create_timer(
name=body.name, fires_at_iso=body.fires_at,
message=body.message, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/triggers/watcher")
def triggers_create_watcher(body: TriggerWatcherBody):
try:
return triggers_mod.create_watcher(
name=body.name, condition=body.condition,
message=body.message,
check_interval_sec=body.check_interval_sec,
throttle_sec=body.throttle_sec,
author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.patch("/triggers/{name}")
def triggers_patch(name: str, body: TriggerPatch):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
try:
return triggers_mod.update(name, patch)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/triggers/{name}")
def triggers_delete(name: str):
try:
triggers_mod.delete(name)
except ValueError as exc:
raise HTTPException(404, str(exc))
return {"deleted": name}
# ─── Skills ─────────────────────────────────────────────────────────
class SkillCreate(BaseModel):
+60 -1
View File
@@ -115,16 +115,75 @@ def build_skills_section(skills: List[dict]) -> str:
return "\n".join(lines)
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. "
"Du legst sie an wenn Stefan sagt 'erinner mich an X' oder 'sag bescheid wenn Y'.")
lines.append("")
if triggers:
lines.append("### Aktuelle Trigger")
for t in triggers:
active = t.get("active", True)
mark = "" if active else " [INAKTIV]"
if t["type"] == "timer":
lines.append(f"- **{t['name']}**{mark} (timer) feuert {t.get('fires_at')}: \"{t.get('message','')[:80]}\"")
elif t["type"] == "watcher":
lines.append(f"- **{t['name']}**{mark} (watcher) cond=`{t.get('condition')}`: \"{t.get('message','')[:80]}\"")
lines.append("")
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`. "
"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, GPS-Naehe).")
lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.")
lines.append("")
lines.append("### GPS-Watcher mit near()")
lines.append(
"Wenn du einen Watcher mit `near()` anlegst: die App sendet GPS-Position "
"nur kontinuierlich wenn Tracking AN ist (Default: AUS, Akku-Schutz). "
"Rufe dafuer `request_location_tracking(on=true, reason=\"...\")` auf "
"bevor oder gleich nach dem trigger_watcher. Sonst hat current_lat/lon "
"veraltete Werte und der Watcher feuert nie. "
"Beim Loeschen des letzten GPS-Watchers (trigger_cancel) wieder "
"`request_location_tracking(on=false)` aufrufen.")
return "\n".join(lines)
def build_system_prompt(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
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."""
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
parts = [build_hot_memory_section(pinned)]
if skills:
parts.append("")
parts.append(build_skills_section(skills))
if condition_vars:
parts.append("")
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+229
View File
@@ -0,0 +1,229 @@
"""
Triggers — passive Aufweck-Quellen fuer ARIA.
Skills sind aktiv (ARIA ruft sie). Triggers sind passiv — das System ruft
ARIA wenn ein Event passiert. Drei Typen:
timer Einmalig zu einem festen Zeitpunkt
watcher Recurring: Condition pruefen, bei True → feuern (mit Throttle)
cron Cron-Expression (vorerst nicht implementiert, Platzhalter)
Layout:
/data/triggers/<name>.json Manifest pro Trigger
/data/triggers/logs/<name>.jsonl Append-only Log pro Feuerung
Polling-Kosten: Brain-internes Background-Polling (kein LLM-Call).
ARIA wird nur aufgeweckt wenn ein Trigger tatsaechlich feuert.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
TRIGGERS_DIR = Path(os.environ.get("TRIGGERS_DIR", "/data/triggers"))
LOGS_DIR = TRIGGERS_DIR / "logs"
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
VALID_TYPES = {"timer", "watcher", "cron"}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _safe_name(name: str) -> str:
if not isinstance(name, str) or not NAME_RE.match(name):
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
return name
def _path(name: str) -> Path:
return TRIGGERS_DIR / f"{_safe_name(name)}.json"
def _ensure_dirs():
TRIGGERS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
# ─── CRUD ───────────────────────────────────────────────────────────
def list_triggers(active_only: bool = False) -> list[dict]:
if not TRIGGERS_DIR.exists():
return []
out: list[dict] = []
for f in sorted(TRIGGERS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
if active_only and not data.get("active", True):
continue
out.append(data)
except Exception as e:
logger.warning("Trigger lesen %s: %s", f, e)
return out
def read(name: str) -> Optional[dict]:
p = _path(name)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("Trigger %s lesen: %s", name, e)
return None
def write(name: str, data: dict) -> None:
_ensure_dirs()
data["updated_at"] = _now_iso()
p = _path(name)
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(p)
def delete(name: str) -> None:
p = _path(name)
if not p.exists():
raise ValueError(f"Trigger '{name}' nicht gefunden")
p.unlink()
# Logs auch wegraeumen
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
if log_file.exists():
log_file.unlink()
def update(name: str, patch: dict) -> dict:
data = read(name)
if data is None:
raise ValueError(f"Trigger '{name}' nicht gefunden")
allowed = {"active", "message", "condition", "throttle_sec",
"check_interval_sec", "fires_at"}
for k, v in patch.items():
if k in allowed:
data[k] = v
write(name, data)
return data
# ─── Create-Helpers (typ-spezifisch) ────────────────────────────────
def create_timer(
name: str,
fires_at_iso: str,
message: str,
author: str = "aria",
) -> dict:
_safe_name(name)
if _path(name).exists():
raise ValueError(f"Trigger '{name}' existiert schon")
# ISO validieren
try:
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00"))
except Exception:
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
data = {
"name": name,
"type": "timer",
"active": True,
"author": author,
"created_at": _now_iso(),
"fires_at": fires_at_iso,
"message": message,
"fire_count": 0,
"last_fired_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
return data
def create_watcher(
name: str,
condition: str,
message: str,
check_interval_sec: int = 300,
throttle_sec: int = 3600,
author: str = "aria",
) -> dict:
_safe_name(name)
if _path(name).exists():
raise ValueError(f"Trigger '{name}' existiert schon")
# Condition parsen-pruefen (wirft bei Syntax-Fehler)
from watcher import parse_condition
parse_condition(condition) # nur Validate
if check_interval_sec < 30:
check_interval_sec = 30 # nicht oefter als alle 30s pruefen
if throttle_sec < 0:
throttle_sec = 0
data = {
"name": name,
"type": "watcher",
"active": True,
"author": author,
"created_at": _now_iso(),
"condition": condition,
"check_interval_sec": int(check_interval_sec),
"throttle_sec": int(throttle_sec),
"message": message,
"fire_count": 0,
"last_fired_at": None,
"last_checked_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
return data
# ─── Feuern + Log ───────────────────────────────────────────────────
def mark_fired(name: str) -> dict:
data = read(name)
if data is None:
raise ValueError(f"Trigger '{name}' nicht gefunden")
data["fire_count"] = int(data.get("fire_count", 0)) + 1
data["last_fired_at"] = _now_iso()
# Timer: nach Feuern auto-deaktivieren (one-shot)
if data.get("type") == "timer":
data["active"] = False
write(name, data)
return data
def append_log(name: str, entry: dict) -> None:
_ensure_dirs()
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
record = {"ts": _now_iso()}
record.update(entry)
try:
with log_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("Trigger-Log append %s: %s", name, e)
def list_logs(name: str, limit: int = 50) -> list[dict]:
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
if not log_file.exists():
return []
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
out: list[dict] = []
for line in lines[-limit:]:
try:
out.append(json.loads(line))
except Exception:
pass
return out
except Exception:
return []
+310
View File
@@ -0,0 +1,310 @@
"""
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
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"
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
from datetime import datetime
from pathlib import Path
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 ──────────────────────────────────────────────
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 _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 + near()-Helper."""
free_gb, free_pct = _disk_stats()
now = datetime.now()
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
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()],
"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]:
"""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": "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 = (
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,
ast.Call,
)
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}")
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 _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:
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 enthalten Variablen + near()-Funktion → 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)
+99 -28
View File
@@ -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/<key>.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.
@@ -1086,6 +1125,12 @@ class ARIABridge:
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
# ARIA ist fertig — App's "ARIA denkt..." Indicator zurueck auf idle.
# _last_chat_final_at bewusst NICHT setzen: die 3s-Cooldown war fuer
# trailing OpenClaw-Activity-Events; bei Voice-Chat wuerde sie die
# naechste thinking-Welle unterdruecken.
await self._emit_activity("idle", "")
# ── Mode Persistence (global, nicht pro Geraet) ──────
_MODE_FILE = "/shared/config/mode.json"
@@ -1250,12 +1295,9 @@ class ARIABridge:
# / Diagnostic-Reload als History-Quelle gelesen.
self._append_chat_backup({"role": "user", "text": text, "source": source})
# agent_activity broadcasten (App + Diagnostic "ARIA denkt..." Indicator)
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "thinking"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
await self._emit_activity("thinking", "")
def _do_call():
try:
@@ -1272,11 +1314,7 @@ class ARIABridge:
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_call)
if status != 200:
logger.error("[brain] /chat fehlgeschlagen: status=%s body=%s", status, body[:200])
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
await self._send_to_rvs({
"type": "chat",
"payload": {
@@ -1291,21 +1329,13 @@ class ARIABridge:
data = json.loads(body)
except Exception:
logger.error("[brain] /chat lieferte ungueltiges JSON: %s", body[:200])
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
return
reply = (data.get("reply") or "").strip()
if not reply:
logger.warning("[brain] /chat: leerer Reply")
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
return
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
@@ -1320,6 +1350,26 @@ class ARIABridge:
})
logger.info("[brain] ARIA hat einen Skill erstellt: %s",
event.get("skill", {}).get("name"))
elif etype == "trigger_created":
await self._send_to_rvs({
"type": "trigger_created",
"payload": event.get("trigger", {}),
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[brain] ARIA hat einen Trigger angelegt: %s",
event.get("trigger", {}).get("name"))
elif etype == "location_tracking":
# ARIA bittet die App das GPS-Tracking ein-/auszuschalten
await self._send_to_rvs({
"type": "location_tracking",
"payload": {
"on": bool(event.get("on")),
"reason": event.get("reason") or "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[brain] location_tracking Request: on=%s (%s)",
event.get("on"), event.get("reason", ""))
# _process_core_response uebernimmt alles weitere:
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
@@ -1331,6 +1381,8 @@ class ARIABridge:
await self._process_core_response(reply, {})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "")
# Originaler Fallback-Send (toter Code, _emit_activity uebernimmt jetzt)
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
@@ -1478,6 +1530,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.
@@ -1871,6 +1926,17 @@ class ARIABridge:
logger.warning("[rvs] file_delete_request: %s", e)
return
elif msg_type == "location_update":
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
# /shared/state/location.json geschrieben, damit Watcher-Trigger
# near()-Conditions auswerten koennen.
lat = payload.get("lat")
lon = payload.get("lon") or payload.get("lng")
if lat is not None and lon is not None:
self._persist_location({"lat": lat, "lon": lon})
logger.debug("[gps] location_update: %.5f, %.5f", float(lat), float(lon))
return
elif msg_type == "container_restart":
# App-Button "Container neu" — leitet generisch an Diagnostic
# weiter. Whitelist ist im Diagnostic-Server.
@@ -1960,6 +2026,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 "",
@@ -2050,13 +2119,11 @@ class ARIABridge:
if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
# mit dem chat-Pfad damit das Verhalten konsistent ist.
core_text = self._build_core_text(text, interrupted, location)
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
# Reihenfolge wichtig: STT-Text ZUERST broadcasten damit die App
# die Voice-Bubble sofort mit dem erkannten Text aktualisieren
# kann — send_to_core blockt danach synchron auf Brain (kann
# dauern), wuerde sonst die Anzeige verzoegern.
try:
stt_payload = {
"text": text,
@@ -2080,6 +2147,10 @@ class ARIABridge:
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
+303 -4
View File
@@ -221,6 +221,7 @@
<button class="main-nav-btn active" onclick="switchMainTab('main')">Main</button>
<button class="main-nav-btn" onclick="switchMainTab('brain')">Gehirn</button>
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
</div>
@@ -899,6 +900,74 @@
</div>
</div><!-- /tab-skills -->
<!-- ══════ TAB: Trigger ══════ -->
<div id="tab-triggers" class="main-tab">
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">Trigger <button class="info-btn" onclick="showInfo('triggers')" title="Was sind Trigger?"></button></h2>
<div style="display:flex;gap:6px;">
<button class="btn secondary" onclick="loadTriggers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
<button class="btn" onclick="openTriggerCreate()" style="padding:4px 10px;font-size:11px;">+ Neu</button>
</div>
</div>
<div class="card" style="margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;">
Trigger sind passive Aufweck-Quellen. Skills sind aktiv (ARIA ruft sie),
Trigger sind passiv (System ruft ARIA wenn ein Event passiert). Polling
kostet keine Tokens — nur das Feuern verbraucht eine Anfrage.
</p>
</div>
<div class="card">
<div id="triggers-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
</div>
</div>
</div><!-- /tab-triggers -->
<!-- Trigger-Create Modal -->
<div class="modal-overlay" id="trigger-modal">
<div class="modal-box" style="max-width:600px;">
<div class="modal-header">
<h3>Neuer Trigger</h3>
<button class="modal-close" onclick="closeTriggerModal()">&times;</button>
</div>
<div class="modal-body" style="padding:16px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Typ</label>
<select id="trigger-type" onchange="onTriggerTypeChange()" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="timer">Timer — einmalig zu festem Zeitpunkt</option>
<option value="watcher">Watcher — wenn Bedingung wahr</option>
</select>
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Name</label>
<input type="text" id="trigger-name" placeholder="z.B. pasta, disk-warn" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<!-- Timer-spezifisch -->
<div id="trigger-timer-fields">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
</div>
<!-- Watcher-spezifisch -->
<div id="trigger-watcher-fields" style="display:none;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Condition (siehe Variablen unten)</label>
<input type="text" id="trigger-condition" placeholder="z.B. disk_free_gb < 5" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Check-Intervall (Sek, min 30)</label>
<input type="number" id="trigger-check-interval" min="30" max="86400" value="300" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Throttle zwischen Feuerungen (Sek)</label>
<input type="number" id="trigger-throttle" min="0" max="86400" value="3600" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<div id="trigger-vars-info" style="font-size:10px;color:#555570;line-height:1.6;margin-bottom:10px;"></div>
</div>
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Nachricht</label>
<textarea id="trigger-message" rows="3" placeholder="Was soll ARIA sagen wenn der Trigger feuert?" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;resize:vertical;margin-bottom:10px;"></textarea>
<div id="trigger-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:4px;display:none;"></div>
</div>
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
<button class="btn" onclick="saveTrigger()">Anlegen</button>
</div>
</div>
</div>
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<div class="modal-overlay" id="info-modal">
<div class="modal-box" style="max-width:640px;">
@@ -1274,6 +1343,14 @@
}
return;
}
if (msg.type === 'trigger_created') {
addTriggerCreatedBubble(msg.payload || {});
// Falls Triggers-Tab offen: refreshen
if (document.getElementById('tab-triggers') && document.getElementById('tab-triggers').classList.contains('visible')) {
loadTriggers();
}
return;
}
if (msg.type === 'chat_delta') { return; }
if (msg.type === 'chat_error') {
addChat('error', msg.error, 'chat:error');
@@ -1282,10 +1359,20 @@
if (msg.type === 'rvs_chat') {
const p = msg.msg.payload || {};
const sender = p.sender || '?';
// ARIA-Antworten kommen schon via Gateway (chat:final) — nicht nochmal via RVS anzeigen
if (sender === 'aria') return;
const chatType = 'sent';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
// Frueher: 'aria' kam parallel via OpenClaw-Gateway (chat:final) UND via RVS,
// RVS wurde dedupliziert. Gateway ist raus — ARIA-Antworten kommen jetzt
// ausschliesslich via RVS, also nicht mehr blocken.
let chatType, label;
if (sender === 'aria') {
chatType = 'received';
label = 'ARIA';
} else if (sender === 'stt') {
chatType = 'sent';
label = '\uD83C\uDFA4 Spracheingabe';
} else {
chatType = 'sent';
label = `via RVS (${sender})`;
}
addChat(chatType, p.text || '?', label, { location: p.location });
return;
}
@@ -1750,6 +1837,37 @@
}
}
/** ARIA hat einen Trigger angelegt — Bubble mit Details. */
function addTriggerCreatedBubble(trigger) {
const name = trigger.name || '(unbenannt)';
const ttype = trigger.type || 'timer';
const msg = trigger.message || '';
const detail = ttype === 'timer'
? `feuert: <code>${escapeHtml(trigger.fires_at || '?')}</code>`
: `wenn: <code>${escapeHtml(trigger.condition || '?')}</code>`;
const html = `
<div style="font-weight:bold;color:#FFD60A;">⏰ ARIA hat einen Trigger angelegt</div>
<div style="margin-top:4px;color:#E0E0F0;">
<strong>${escapeHtml(name)}</strong>
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(ttype)})</span>
</div>
<div style="color:#8888AA;font-size:11px;margin-top:2px;">${detail}</div>
<div style="color:#8888AA;font-size:12px;margin-top:2px;">"${escapeHtml(msg)}"</div>
<div class="meta">
ARIA-Trigger — ${new Date().toLocaleTimeString('de-DE')} ·
<a href="#" onclick="event.preventDefault();switchMainTab('triggers');" style="color:#FFD60A;">im Trigger-Tab ansehen</a>
</div>`;
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
const el = document.createElement('div');
el.className = 'chat-msg received';
el.style.borderLeft = '3px solid #FFD60A';
el.innerHTML = html;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
}
/** ARIA hat einen Skill erstellt — als auffaellige Bubble anzeigen. */
function addSkillCreatedBubble(skill) {
const name = skill.name || '(unbenannt)';
@@ -2664,6 +2782,173 @@
loadFiles();
} else if (tab === 'skills') {
loadSkills();
} else if (tab === 'triggers') {
loadTriggers();
}
}
// ── Triggers-Verwaltung ────────────────────────────────
let triggersCache = [];
async function loadTriggers() {
const el = document.getElementById('triggers-list');
if (!el) return;
try {
const r = await fetch('/api/brain/triggers/list');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
triggersCache = d.triggers || [];
renderTriggersList();
} catch (e) {
el.innerHTML = `🔴 Brain nicht erreichbar (${e.message})`;
}
}
function renderTriggersList() {
const el = document.getElementById('triggers-list');
if (!el) return;
if (!triggersCache.length) {
el.innerHTML = '<div style="padding:8px;color:#555570;">Keine Trigger vorhanden. Sag ARIA "erinner mich in 5 Minuten" oder leg manuell einen an.</div>';
return;
}
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '';
el.innerHTML = triggersCache.map(t => {
const active = t.active !== false;
const statusBadge = active
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;">aktiv</span>'
: '<span style="background:#55557022;color:#888;padding:1px 6px;border-radius:3px;font-size:10px;">INAKTIV</span>';
const typeBadge = t.type === 'timer'
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">⏱ TIMER</span>'
: '<span style="background:#0096FF22;color:#0096FF;padding:1px 6px;border-radius:3px;font-size:10px;">👁 WATCHER</span>';
const authorBadge = t.author === 'aria'
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">von ARIA</span>'
: '';
let detailLine = '';
if (t.type === 'timer') {
detailLine = `feuert: <code>${escapeHtml(t.fires_at || '?')}</code>`;
} else if (t.type === 'watcher') {
detailLine = `wenn: <code>${escapeHtml(t.condition || '?')}</code> · check alle ${t.check_interval_sec}s · throttle ${t.throttle_sec}s`;
}
return `
<div style="border-bottom:1px solid #1E1E2E;padding:8px 0;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="flex:1;color:#E0E0F0;font-weight:bold;">${escapeHtml(t.name)}</span>
${statusBadge} ${typeBadge} ${authorBadge}
<span style="color:#555570;font-size:10px;">${t.fire_count || 0}× · zuletzt ${fmtDate(t.last_fired_at)}</span>
</div>
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
</div>
</div>
`;
}).join('');
}
async function toggleTriggerActive(name, newActive) {
try {
await fetch('/api/brain/triggers/' + encodeURIComponent(name), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: newActive }),
});
loadTriggers();
} catch (e) {
alert('Toggle fehlgeschlagen: ' + e.message);
}
}
async function deleteTrigger(name) {
if (!confirm(`Trigger "${name}" wirklich löschen?`)) return;
try {
await fetch('/api/brain/triggers/' + encodeURIComponent(name), { method: 'DELETE' });
loadTriggers();
} catch (e) {
alert('Löschen fehlgeschlagen: ' + e.message);
}
}
function onTriggerTypeChange() {
const t = document.getElementById('trigger-type').value;
document.getElementById('trigger-timer-fields').style.display = t === 'timer' ? '' : 'none';
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
}
async function openTriggerCreate() {
document.getElementById('trigger-type').value = 'timer';
document.getElementById('trigger-name').value = '';
document.getElementById('trigger-timer-minutes').value = '10';
document.getElementById('trigger-condition').value = '';
document.getElementById('trigger-check-interval').value = '300';
document.getElementById('trigger-throttle').value = '3600';
document.getElementById('trigger-message').value = '';
document.getElementById('trigger-modal-error').style.display = 'none';
onTriggerTypeChange();
// 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) {
const vars = (d.variables || []).map(v =>
`<code>${escapeHtml(v.name)}</code>=${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
).join(' · ');
const fns = (d.functions || []).map(f =>
`<code>${escapeHtml(f.signature)}</code> — ${escapeHtml(f.desc)}`
).join('<br>');
info.innerHTML =
'<strong>Variablen:</strong> ' + vars +
(fns ? '<br><br><strong>Funktionen:</strong><br>' + fns : '');
}
} catch {}
document.getElementById('trigger-modal').classList.add('open');
}
function closeTriggerModal() {
document.getElementById('trigger-modal').classList.remove('open');
}
async function saveTrigger() {
const errEl = document.getElementById('trigger-modal-error');
errEl.style.display = 'none';
const ttype = document.getElementById('trigger-type').value;
const name = document.getElementById('trigger-name').value.trim();
const message = document.getElementById('trigger-message').value.trim();
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
try {
let url, body;
if (ttype === 'timer') {
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
const firesAt = new Date(Date.now() + mins * 60 * 1000).toISOString();
url = '/api/brain/triggers/timer';
body = { name, fires_at: firesAt, message, author: 'stefan' };
} else {
const condition = document.getElementById('trigger-condition').value.trim();
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
url = '/api/brain/triggers/watcher';
body = {
name, condition, message, author: 'stefan',
check_interval_sec: parseInt(document.getElementById('trigger-check-interval').value, 10) || 300,
throttle_sec: parseInt(document.getElementById('trigger-throttle').value, 10) || 3600,
};
}
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const t = await r.text();
throw new Error('HTTP ' + r.status + ': ' + t.slice(0, 200));
}
closeTriggerModal();
loadTriggers();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
@@ -3525,6 +3810,20 @@
<p><strong>Warn-Schwellen:</strong> 5h-Counter wird gelb bei 80%, rot bei 90% des Plan-Limits.</p>
`,
},
'triggers': {
title: 'Trigger — passive Aufweck-Quellen',
html: `
<p><strong>Skills</strong> sind aktiv (ARIA ruft sie auf).
<strong>Trigger</strong> sind passiv: das System ruft ARIA wenn ein Event passiert.</p>
<p><strong>Timer:</strong> einmalig zu festem Zeitpunkt. "Erinner mich in 10min" → ARIA legt einen Timer an.</p>
<p><strong>Watcher:</strong> pruefen alle paar Minuten eine Bedingung (z.B. <code>disk_free_gb &lt; 5</code>),
feuern wenn wahr. Throttle verhindert Spam (Default: max 1× pro Stunde).</p>
<p><strong>Token-Effizienz:</strong> das Polling laeuft lokal im Brain-Container ohne Claude-Calls.
Erst wenn ein Trigger tatsaechlich feuert, wird ARIA aufgeweckt und antwortet.</p>
<p><strong>Wer legt sie an:</strong> entweder du (Diagnostic-Tab + Neu) oder ARIA selbst auf deinen Wunsch
im Chat ("sag bescheid wenn Disk unter 5GB").</p>
`,
},
'bootstrap': {
title: 'Bootstrap & Migration — die drei Wege',
html: `
+28 -1
View File
@@ -55,6 +55,14 @@ Wichtige Mechanismen:
### Bugs / Fixes
- [x] **"ARIA denkt..." haengt nach Brain-Antwort** (App + Diagnostic): `send_to_core` schickte `thinking` direkt via `_send_to_rvs`, hat aber `_last_activity_state` nicht gepflegt — der spaetere `_emit_activity("idle")` wurde dedupliziert und verschluckt. Fix: durchgehend `_emit_activity` fuer beide Zustaende
- [x] **Such-Scroll in App-Chat springt jetzt zur Treffer-Bubble**: `scrollToIndex` wurde zu frueh gerufen + `viewPosition: 0.4` schoss vorbei. Fix: `requestAnimationFrame` + `viewPosition: 0.5` + `onScrollToIndexFailed`-Fallback mit averageItemLength-Schaetzung + 250ms-Retry
- [x] **STT-Bubble bekommt den Text jetzt sofort** (nicht erst mit ARIAs Antwort): `_process_app_audio` rief erst `send_to_core` (blockt synchron) und DANN STT-Broadcast. Fix: Reihenfolge getauscht — STT raus, dann Core-Call
- [x] **ARIA-Antworten landen wieder in der Diagnostic**: `if (sender === 'aria') return;` im `rvs_chat`-Handler war OpenClaw-Leiche und filterte die neuen Brain-Antworten weg. Fix: aria → received-Bubble
- [x] **Brain-Card im Main-Tab zeigt jetzt Live-Status**: `updateState` ueberschrieb die Card mit altem `state.gateway`-Text aus OpenClaw-Zeiten. Fix: `updateState` laesst Brain-Card unangetastet, `loadBrainStatus` synchronisiert beide Cards (Main + Gehirn-Tab) alle 15s
- [x] **App-Chat-Sync zeigte veralteten Stand**: `since:lastSync` war diff-only — wenn Server geleert war, blieb die App-History stehen. Fix: `since:0, limit:200` komplett-Replace (Server = Source of Truth). Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten
- [x] **Konversation-Reset leert jetzt beides**: vorher leerte der Button nur das Brain-Memory, `chat_backup.jsonl` blieb. Fix: ein Button feuert `Promise.all` auf `/api/brain/conversation/reset` + `/api/chat-history-clear`, plus `chat_cleared`-Broadcast via RVS damit App + Diagnostic sich live leeren
- [x] **JS-Crashes beim Diagnostic-Laden behoben**: Ghost-IDs aus OpenClaw-Zeiten (`gw-dot`, `openclaw-config`, `btn-core-term`, `core-auth`, `perms-status`, `rc-compact-after`) wurden null-referenziert. Fix: null-safe oder Code raus
- [x] Diagnostic: "ARIA denkt..." bleibt nicht mehr stehen
- [x] App: "ARIA denkt..." Indicator + Abbrechen-Button (Bridge spiegelt agent_activity via RVS)
- [x] Textnachrichten werden von ARIA beantwortet (Bridge chat handler fix)
@@ -239,7 +247,8 @@ Skills mit Tool-Use.
- [x] Memory-Destillat: bei >60 Turns automatisch 30 aelteste → fact-Memories via Claude-Call
- [x] Hot Memory (pinned) + Cold Memory (Top-5 semantisch) im System-Prompt
- [x] Manueller Destillat-Trigger + Konversation-Reset (Brain + Diagnostic chat_backup gleichzeitig)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
- [x] Bridge schreibt chat_backup.jsonl bei jedem Turn (User + ARIA + ARIA-Files)
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth). Wenn Server leer → App leert auch. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten. Plus chat_cleared Live-Update wenn Diagnostic die History wiped.
### Skills-System (Phase B Punkt 4)
@@ -249,6 +258,24 @@ Skills mit Tool-Use.
- [x] Diagnostic Skills-Tab: Liste, README, Logs pro Run, Activate/Deactivate/Delete, Export/Import als tar.gz
- [x] skill_created Live-Notification: gelbe Bubble in App + Diagnostic sobald ARIA selbst einen Skill anlegt
### Triggers-System (Phase B Punkt 5)
- [x] **Filesystem-Layer** unter `/data/triggers/<name>.json` + `logs/<name>.jsonl` pro Trigger
- [x] **Timer** (one-shot, ISO-Timestamp) — "erinner mich in 10 Minuten an X" → ARIA legt via `trigger_timer`-Tool an, Background-Loop feuert zum Stichzeitpunkt einmal
- [x] **Watcher** (recurring) — feuert wenn `condition` true wird, mit Throttle (min_seconds_between_fires) gegen Spam. Checks alle 30s
- [x] **Sicherer Condition-Parser** via Python `ast`-Module (Whitelist statt `eval`): nur `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`, Konstanten + Variablennamen aus Whitelist
- [x] **Built-in Variablen**: `disk_free_gb`, `disk_free_pct`, `ram_free_mb`, `cpu_load_1min`, `uptime_sec`, `hour_of_day`, `minute_of_hour`, `day_of_month`, `month`, `year`, `day_of_week`, `is_weekend`, `unix_timestamp`, `current_lat`, `current_lon`, `location_age_sec`, `last_user_message_ago_sec`, `memory_count`, `pinned_count`, `rvs_connected`
- [x] **near(lat, lon, radius_m) Funktion** im Parser (Haversine) — GPS-Geofencing fuer Blitzer-Warner / Ankunft-Erinnerungen
- [x] **Background-Loop** im Brain-Container (Lifespan async task): laeuft alle 30s, prueft alle aktiven Trigger, ruft bei Match `agent.chat(prompt, source="trigger")` mit System-Praefix → ARIA reagiert wie auf eine Frage von Stefan, kann TTS sprechen / Skills starten / weitere Trigger anlegen
- [x] **Diagnostic Trigger-Tab**: Liste aktiver Trigger mit Logs, Anlegen-Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Variablen + Funktionen, Beispiele
- [x] **App Live-Notification**: `trigger_created`-Bubble (gelb) sobald ARIA selbst einen Trigger anlegt — User sieht sofort dass die Bitte angekommen ist
- [x] **GPS-Tracking via App** (`@react-native-community/geolocation` watchPosition, distanceFilter 30m, interval 15s) — Singleton-Service in `gpsTracking.ts`, Toggle in Settings → Standort, persistiert AsyncStorage, Restore beim App-Start
- [x] **`request_location_tracking`-Tool**: ARIA kann das Tracking via `location_tracking`-Event an-/ausschalten — Bridge forwarded an App, App startet/stoppt watchPosition. ARIA tut das automatisch wenn sie einen Watcher mit `near()` anlegt
- [x] **`location_update`-Forwarding**: App schickt alle 15s/30m ein `location_update {lat,lon}`, Bridge persistiert in `/shared/state/location.json`, Watcher liest beim Check
- [x] **Activity-Persistenz**: `/shared/state/activity.json` traegt User-Message-Zeitstempel, damit `last_user_message_ago_sec` als Variable verfuegbar ist
- [x] **`trigger_cancel`** + **`trigger_list`** als Tools — ARIA kann eigene Trigger verwalten
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
### Diagnostic / App Features (drumherum)
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
+2
View File
@@ -25,6 +25,8 @@ const ALLOWED_TYPES = new Set([
"xtts_export_voice", "xtts_voice_exported",
"xtts_import_voice", "xtts_voice_imported",
"skill_created",
"trigger_created",
"location_update", "location_tracking",
"chat_history_request", "chat_history_response", "chat_cleared",
"file_delete_batch_request", "file_delete_batch_response",
"file_zip_request", "file_zip_response",