Compare commits

...

16 Commits

Author SHA1 Message Date
duffyduck d54d37061f release: bump version to 0.1.4.4 2026-05-14 23:09:08 +02:00
duffyduck a6afec0e11 release: bump version to 0.1.4.3 2026-05-14 22:59:25 +02:00
duffyduck 205112021b fix(chat): Such-Scroll + Doppel-Send-Hang + Delivery-Handshake
Drei Etappen Chat-Fixes:

Etappe 1 — Such-Scroll permanent springen weg:
- invertedMessages raus aus dem useEffect-Deps; neue ARIA-Nachrichten triggern den Scroll-Effect nicht mehr. Aktueller Snapshot via Ref.
- onScrollToIndexFailed: statt 3 cascading Retries (120/320/600ms) nur noch EINE Retry nach 300ms. Cascading-Retries waren der Endlos-Cascade-Bug (jeder Failed-Retry triggerte 3 weitere).

Etappe 2 — AsyncStorage-Race + Stuck-Thinking:
- Init-Load merged statt overwrite — Nachrichten die zwischen Mount und Load-Done reinkommen werden nicht mehr verschluckt.
- Stuck-Thinking-Watchdog: 180s ohne agent_activity-Update → Auto-Reset auf idle + Timeout-Bubble. Gegen "App haengt auf 'ARIA denkt'".

Etappe 3 — Delivery-Handshake (WhatsApp-Style):
- Pro User-Bubble: clientMsgId + deliveryStatus (queued/sending/sent/delivered/failed).
- Offline-Queue: Send waehrend disconnected → 'queued' → flush bei Reconnect.
- Bridge sendet chat_ack zurueck → Bubble auf 'sent' (✓).
- ARIA-Reply → alle vorigen User-Bubbles 'delivered' (✓✓).
- ACK-Timeout 30s, bis zu 3 Retries, danach 'failed' (rotes Tap-fuer-Retry).
- Bridge: LRU-Idempotenz (200 cmids) verhindert Doppelte beim Retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:55:44 +02:00
duffyduck 853f2737f1 release: bump version to 0.1.4.2 2026-05-14 22:29:31 +02:00
duffyduck 7c61107f87 release: bump version to 0.1.4.1 2026-05-14 22:16:17 +02:00
duffyduck 7a22474efd feat(chat): Jump-down-Button + Sprung-an-Text-Anfang + Vision-Issue raus
Drei kleine UX-Fixes im Chat:

1. Jump-Down-Button (↓): Bei inverted FlatList erscheint rechts ueber
   der Eingabe ein blauer FAB, sobald man mehr als 250px von der
   neuesten Nachricht weg gescrollt ist. Tap → scrollToOffset(0)
   animated → wieder unten. Auto-hide wenn man unten ist.

2. Such-Sprung landet jetzt am TEXT-ANFANG der Treffer-Bubble:
   viewPosition 0.5 (Mitte) → 0 (Item-Top am Viewport-Top). Plus
   Retry-Folge (180/420/800ms) gegen Layout-Race bei langen Listen.
   Vorher musste man oft nochmal hoch scrollen um den Anfang zu sehen.
   onScrollToIndexFailed-Fallback genauso mit viewPosition 0.

3. issue.md: "Bilder: Claude Vision direkt nutzen" raus aus den
   offenen Punkten — ist durch Stufe E (Memory-Anhaenge, Read-Tool
   multi-modal) längst geloest. ARIA sieht Bilder echt.

Folge-Etappen: Such-Sprung-Resilienz war Teil davon (mehrere Retries
abgedeckt). Naechste Brocken: Doppel-Send-Haenger, AsyncStorage-Race,
Offline-Queue mit Idempotenz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:10:26 +02:00
duffyduck f2cf4e0d58 release: bump version to 0.1.4.0 2026-05-14 18:39:58 +02:00
duffyduck db4bebfa57 docs: README + issue — drei GPS-Trigger-Modi + Tick-Frequenz-Fix
README.md:
- Diagnostic-Trigger-Tab-Beschreibung erweitert um die drei GPS-Funktionen
  (near / entered_near / left_near) mit Use-Cases pro Modus
- Plus Auflösung erklaert: 8s-Tick + event-getrieben bei location_update
  fuer Auto-Vorbeifahrten. 5-min-Age-Schutz gegen Phantom-Fires
- Phase B Punkt 5 in der Roadmap entsprechend nachgezogen

issue.md: neuer Block "GPS-Trigger-Verbesserungen" mit drei Punkten —
Timing-Fix, Age-Schutz, drei Modi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:33:04 +02:00
duffyduck 435b77e1df feat(trigger): entered_near + left_near — drei Modi fuer near()-Watcher
Stefan: bei aktuellen near()-Watcher gibt's nur "solange drin". Reale
Szenarien wollen aber differenzieren:
- VORWARNUNG vor Ziel (Blitzer-Warner 2 km vorher) → entered_near mit grossem r
- ANKUNFT exakt am Ziel → entered_near mit kleinem r
- VERLASSEN (Parkplatz, hast du was vergessen) → left_near
- KONTINUIERLICH-DRIN (bin noch in der Naehe?) → near (Default, throttled)

Zwei neue Funktionen in der Condition-Whitelist:

- entered_near(lat, lon, r): True NUR im Moment des Uebergangs
  draussen → innen. Fires einmal pro Eintritt.
- left_near(lat, lon, r): True NUR im Moment des Uebergangs innen →
  draussen. Fires einmal pro Austritt.

State-Tracking:
- pro Trigger pro near-Aufruf wird der letzte Auswertungs-Wert (true/
  false) im Watcher-Manifest gespeichert (Field "near_states", Key
  "lat.6,lon.6,radius"). Background-Loop liest's vor dem Eval, gibt's
  per collect_variables(prev_near_states=...) in die Closure, schreibt
  nach dem Eval die neuen Werte zurueck — UNABHAENGIG ob gefeuert
  wurde, sonst greift die Uebergangs-Erkennung nicht.

Background _tick:
- Aufteilung in Watcher-Pass (mit prev_near_states pro Trigger) und
  Timer-Pass (ohne State, gemeinsame vars). Bisher war collect_variables
  einmal pro Tick — jetzt einmal pro Watcher. Disk-Stats sind teuer
  aber unter 30 Watchern unkritisch; bei mehr koennen wir cachen.

ARIA-Tool-Description erweitert (trigger_watcher): erklaert die drei
Modi mit Use-Cases und empfohlenen Throttle-Werten (kurz fuer entered/
left, lang fuer near).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:29:39 +02:00
duffyduck 6f80e442cf fix(trigger): near() fired bei Auto-Vorbeifahrten verpasst — Loop schneller + event-getrieben
Stefan ist mehrmals an einem 300m-near()-Watcher (DRK Kreyenbrueck)
vorbeigefahren, kein Fire. Ursache: Background-Loop tickte alle 30s,
Auto-Durchfahrt durch 600m-Durchmesser-Radius dauert bei 50-120 km/h
nur 18-43 Sekunden — der Tick konnte komplett dazwischen liegen.

Drei Fixes (A + B aus Stefans Vorschlag):

A1. Background-Loop-Frequenz: TICK_SEC 30 → 8.
    Garantiert mind. 2 Checks auch bei 120 km/h durch 300m. Loop ist
    billig (paar Dateilesungen + AST-Eval), Brain merkt das nicht.

A2. near() bekommt Age-Schutz (watcher.py NEAR_MAX_AGE_SEC=300):
    Wenn location_age_sec > 5 min, gilt die Position als unbekannt
    und near() liefert False. Verhindert Phantom-Fires wenn Tracking
    aus ist oder Mobilfunk weg war — vorher haette der letzte
    bekannte Wert weiter ausgewertet werden koennen.

B. Event-getriebener Tick:
    - background.py: tick_now()-Funktion + Module-Slot fuer
      agent_factory damit man von ausserhalb des Lifespan-Pfads
      einen Tick triggern kann
    - main.py: POST /triggers/check-now Endpoint ruft tick_now()
    - bridge: _persist_location feuert nach jedem Save ein fire-and-
      forget POST /triggers/check-now (run_in_executor, timeout 8s,
      blockt nichts wenn Brain stockt)

Damit fires near() sofort wenn die App ein location_update schickt —
Polling ist nur noch der Fallback fuer Watcher OHNE GPS-Bezug
(disk_free, hour_of_day etc.) und als Sicherheits-Tick falls
location_update mal ausfaellt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:16:53 +02:00
duffyduck 0fcbf5e3ed docs: README + issue — Memory-Editor-App + Crash-Reporting + Bugfixes
Heute Tag-2 nach dem Memory-Editor-Hauptbau:

issue.md: neuer Block "App-Memory-Editor + Crash-Reporting" mit 8
Punkten (Bubble-Header dynamic, Tap-Modal, Inbox, Settings-Editor,
RVS-Brain-Proxy, App-Crash-Reporting, memory_search+update Tools,
Bugfixes-Cluster).

README.md:
- App-Features um Notizen-Inbox + Memory-Editor + Bubble-Header
  dynamic + App-Crash-Reporting ergaenzt
- Roadmap um "Memory-Editor in der App" und "App-Crash-Reporting via
  RVS" als eigene Bullets — beide sitzen unter dem letzten
  Memory-Anhaenge-Eintrag und schliessen damit den App-UX-Loop:
  ARIA hat jetzt im Diagnostic UND in der App vollwertiges Memory-
  CRUD inkl. Anhaenge, plus Crashes sind ohne ADB diagnostizierbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:30:15 +02:00
duffyduck 3cf6308b79 release: bump version to 0.1.3.9 2026-05-14 17:17:44 +02:00
duffyduck 7e5a4da659 fix(app): Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled)
Stefan: Memory-Liste in Settings → Gedaechtnis-Sektion laesst sich
nicht scrollen. Klassisches FlatList-in-ScrollView-Problem auf
Android: die aeussere ScrollView (Settings-Screen-Container) faengt
alle Gesten ab, die innere FlatList (MemoryBrowser) bleibt regungslos.

Fix:
- MemoryBrowser FlatList bekommt nestedScrollEnabled={true}
- SettingsScreen-aeussere-ScrollView ebenfalls nestedScrollEnabled
- Plus keyboardShouldPersistTaps="handled" damit Taps auf Filter-
  Buttons nicht von der Tastatur weggefangen werden

In der Inbox-Modal-Nutzung ist's egal — dort hat MemoryBrowser
flex:1 und der Container ist kein ScrollView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:14:21 +02:00
duffyduck d27fcaf342 fix(chat): Cache leer + Datei-Tap → Auto-Re-Download statt Fehlertoast
Stefan: nach App-Cache-Leeren in Settings tippt er auf eine Datei im
Chat oder im memorySaved-Anhang → "Oeffnen fehlgeschlagen" Toast,
aber kein Re-Download. Datei hing als Geister-uri im State (RNFS-Cache
weg, State-attachment.uri zeigt noch auf den entfernten Pfad).

Fix in beiden Tap-Handlern (item.attachments im normalen Chat +
memorySaved.attachments via localUri):

1. RNFS.exists(localPath) pruefen
2. Wenn ja → openFileWithIntent
3. Wenn nein:
   - State bereinigen (uri/localUri auf undefined setzen, UI zeigt
     dann "tippen zum Laden")
   - Toast "Cache leer — lade nach..."
   - file_request via RVS triggern
   - autoOpenPaths-Marker setzen, sodass file_response → Datei
     speichern + automatisch oeffnen

Bilder-Branch hatte schon onError-Handler (Image-Komponente meldet
self) — der ist unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:14:56 +02:00
duffyduck 5b28a065c0 release: bump version to 0.1.3.7 2026-05-14 16:08:30 +02:00
duffyduck e74e1eaf70 fix(app): URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt
Inbox-Crash gefunden via App-Crash-Reporter (commit 21a315c):

  "URLSearchParams.set is not implemented"
  at MemoryBrowser → brainApi.listMemories

React Native's Hermes-Polyfill kennt zwar new URLSearchParams() aber
nicht die .set()-Methode darauf. Pickup-Bug — auf iOS / aelteren
Versionen geht's, Stefan's Android-Build crasht.

Fix: kleine _qs()-Helper im brainApi.ts der einen Query-String aus
einem flachen Object baut, ohne URLSearchParams:
  _qs({q:'cessna', k:5, type:'fact'}) → "?q=cessna&k=5&type=fact"

Plus: undefined/null/empty Werte werden ausgelassen — saubererer als
URLSearchParams.set wo man manuell prefilten muss.

ErrorBoundary aus 21a315c hat den Crash sauber abgefangen, statt der
App-Tot war ne Error-Box im Inbox-Modal mit der vollen Stack-Trace.
Stefan konnte den Log via tools/fetch-app-logs.sh holen ohne ADB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:21 +02:00
13 changed files with 730 additions and 101 deletions
+15 -3
View File
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace - **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 - **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 - **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 - **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), GPS-Funktionen `near() / entered_near() / left_near()` für unterschiedliche Geofencing-Modi
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete - **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 - **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
@@ -319,7 +319,14 @@ 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 - **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. -Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz) - **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. -Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute - **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 oder via `in_seconds` als Server-Berechnung) + **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`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann. - **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **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`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
- `entered_near(lat, lon, r)` — EINMAL beim Eintritt (Übergang außen→innen). Use-Case: Blitzer-Warner mit r=2000 → 2 km Vorwarnung, oder Ankunfts-Erinnerung mit r=100
- `left_near(lat, lon, r)` — EINMAL beim Verlassen (Übergang innen→außen). Use-Case: „Hast du am Parkplatz X was vergessen?"
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete. - **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 - **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
@@ -357,6 +364,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern) - **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live - **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat - **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
- **App-Crash-Reporting**: ungefangene JS-Errors + React-Render-Fehler landen automatisch in `/shared/logs/app.log` via RVS — kein ADB noetig, Logs holen via `tools/fetch-app-logs.sh` oder Diagnostic GET `/api/app-log`. ErrorBoundary verhindert White-Screen, zeigt stattdessen Error-Box im Modal mit Stack-Trace + Schliessen-Button
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden - **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic) - **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS - **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
@@ -867,10 +877,12 @@ 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 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 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 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). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen. - [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, drei GPS-Funktionen `near()` / `entered_near()` / `left_near()` für unterschiedliche Geofencing-Modi, Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Tick-Frequenz 8s + event-getriebene Auswertung bei jedem `location_update` (statt 30s-Polling) damit auch Auto-Vorbeifahrten bei 100+ km/h durch kleine Radien zuverlässig erwischt werden. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten. Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End. - [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr. - [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal. - [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
- [x] **Memory-Editor in der App** (5 Etappen): Notizen-Inbox-Button neben der Lupe oeffnet ein Modal mit allen Spezial-Bubbles aus dem aktuellen Chat plus dem vollen DB-Browser. Tap auf eine Memory → Detail-Modal mit Anhang-Vorschau, Stift-Icon wechselt in Edit-Mode (Felder editieren + Anhaenge hoch-/runterladen + loeschen). Identischer Editor unter Settings → 🧠 Gedaechtnis. Bubble-Header dynamic je nach Aktion (created/updated/deleted). RVS-Brain-Proxy als Fundament (`brain_request`/`brain_response`) damit die App beliebige Brain-HTTP-Endpoints adressieren kann. `memory_search` + `memory_update` als ARIA-Tools damit sie aktiv die DB pruefen und Eintraege patchen kann statt zu fragmentieren.
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary + global JS-Error-Handler + Promise-Rejection-Tracker schicken Crashes als `app_log`-Event durch RVS. Bridge sammelt in `/shared/logs/app.log`, Diagnostic GET `/api/app-log`. `tools/fetch-app-logs.sh` holt die Logs auf die Dev-Maschine (gitignored `.aria-debug/`). Damit kann Stefan unterwegs ohne ADB debuggen — der erste Bug (URLSearchParams in Hermes) wurde so in 5 Minuten gefunden.
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core) - [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [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-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] App: Chat-Suche mit Next/Prev Navigation statt Filter
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10306 versionCode 10404
versionName "0.1.3.6" versionName "0.1.4.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.3.6", "version": "0.1.4.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+6
View File
@@ -169,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
data={filtered} data={filtered}
keyExtractor={m => m.id} keyExtractor={m => m.id}
renderItem={renderItem} renderItem={renderItem}
// nestedScrollEnabled: notwendig damit die FlatList auf Android
// scrollt wenn sie in einer aeusseren ScrollView haengt (Settings-
// Screen ist ScrollView). Ohne das frisst der aeussere ScrollView
// alle Gesten und die innere Liste ist tot.
nestedScrollEnabled={true}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={ ListEmptyComponent={
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}> <Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'} {items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
+410 -49
View File
@@ -114,6 +114,16 @@ interface ChatMessage {
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst * sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
* wenn das chat_backup-Event vom Bridge zurueck kommt. */ * wenn das chat_backup-Event vom Bridge zurueck kommt. */
backupTs?: number; backupTs?: number;
/** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue,
* ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und
* durch die Bridge zurueck-gespiegelt. */
clientMsgId?: string;
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
sendAttempts?: number;
} }
// --- Konstanten --- // --- Konstanten ---
@@ -236,6 +246,7 @@ const ChatScreen: React.FC = () => {
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null); const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null); const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
const [inboxVisible, setInboxVisible] = useState(false); const [inboxVisible, setInboxVisible] = useState(false);
const [showJumpDown, setShowJumpDown] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
@@ -259,6 +270,17 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
// verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset.
const stuckWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearStuckWatchdog = () => {
if (stuckWatchdog.current) {
clearTimeout(stuckWatchdog.current);
stuckWatchdog.current = null;
}
};
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim // ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
// file_response wird die Datei nach dem Speichern direkt mit dem System- // file_response wird die Datei nach dem Speichern direkt mit dem System-
// Intent geoeffnet (PDF-Viewer, Galerie, etc.). // Intent geoeffnet (PDF-Viewer, Galerie, etc.).
@@ -270,6 +292,98 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`; return `msg_${Date.now()}_${messageIdCounter.current}`;
}; };
// Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry,
// Idempotenz). Format: cmsg_<ms>_<rand> — eindeutig genug fuer eine
// 100er-Dedup-Window auf der Bridge.
const nextClientMsgId = (): string =>
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
const ACK_TIMEOUT_MS = 30_000;
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
const MAX_SEND_ATTEMPTS = 3;
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
const ackTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearAckTimer = (cmid: string) => {
const t = ackTimers.current.get(cmid);
if (t) {
clearTimeout(t);
ackTimers.current.delete(cmid);
}
};
// Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach
// ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref
// damit der Inhalt Closures ueberlebt.
const pendingPayloads = useRef<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());
// ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
// die sonst auf einen veralteten Wert zugreifen wuerden.
const connectionStateRef = useRef<ConnectionState>('disconnected');
// Status einer Bubble per clientMsgId aendern (Helper)
const updateMessageStatus = useCallback(
(cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m)));
},
[],
);
// Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking.
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
const dispatchWithAck = useCallback(
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
pendingPayloads.current.set(cmid, { type, payload });
const online = connectionStateRef.current === 'connected';
if (!online) {
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
return;
}
// RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck
rvs.send(type, { ...payload, clientMsgId: cmid });
updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt });
clearAckTimer(cmid);
ackTimers.current.set(
cmid,
setTimeout(() => {
ackTimers.current.delete(cmid);
if (attempt >= MAX_SEND_ATTEMPTS) {
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
} else {
console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1);
dispatchWithAck(cmid, type, payload, attempt + 1);
}
}, ACK_TIMEOUT_MS),
);
},
[updateMessageStatus],
);
// Alle 'queued'-Nachrichten beim Reconnect rausschicken
const flushQueuedMessages = useCallback(() => {
setMessages(prev => {
for (const m of prev) {
if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue;
const pending = pendingPayloads.current.get(m.clientMsgId);
if (!pending) continue;
// Versuchszaehler beibehalten (oder mit 1 starten falls leer)
dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1);
}
return prev;
});
}, [dispatchWithAck]);
// Manueller Retry nach 'failed' (tap auf das ⚠️-Icon)
const retryFailedMessage = useCallback((cmid: string) => {
const pending = pendingPayloads.current.get(cmid);
if (!pending) return;
dispatchWithAck(cmid, pending.type, pending.payload, 1);
}, [dispatchWithAck]);
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System) // sofort greift, ohne Context- oder Event-System)
useEffect(() => { useEffect(() => {
@@ -375,12 +489,24 @@ const ChatScreen: React.FC = () => {
const parsed: ChatMessage[] = JSON.parse(stored); const parsed: ChatMessage[] = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
console.log('[Chat] ${parsed.length} Nachrichten geladen'); console.log('[Chat] ${parsed.length} Nachrichten geladen');
setMessages(parsed); // MERGE statt Overwrite: zwischen Mount und Load-Done koennen
// bereits Nachrichten ankommen (User schreibt sofort, WS-Events
// kommen vor Load-Ende). Vorher hat setMessages(parsed) diese
// ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir
// per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes
// (die sind frischer).
setMessages(prev => {
if (prev.length === 0) return parsed;
const byId = new Map<string, ChatMessage>();
for (const m of parsed) byId.set(m.id, m);
for (const m of prev) byId.set(m.id, m);
return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
});
const maxId = parsed.reduce((max, msg) => { const maxId = parsed.reduce((max, msg) => {
const num = parseInt(msg.id.split('_').pop() || '0', 10); const num = parseInt(msg.id.split('_').pop() || '0', 10);
return num > max ? num : max; return num > max ? num : max;
}, 0); }, 0);
messageIdCounter.current = maxId; messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
} }
} }
} catch (err) { } catch (err) {
@@ -418,6 +544,22 @@ const ChatScreen: React.FC = () => {
// RVS-Nachrichten abonnieren // RVS-Nachrichten abonnieren
useEffect(() => { useEffect(() => {
const unsubMessage = rvs.onMessage((message: RVSMessage) => { const unsubMessage = rvs.onMessage((message: RVSMessage) => {
// chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht.
// Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer.
if (message.type === ('chat_ack' as any)) {
const cmid = (message.payload as any).clientMsgId as string | undefined;
if (cmid) {
clearAckTimer(cmid);
pendingPayloads.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus !== 'delivered'
? { ...m, deliveryStatus: 'sent' }
: m
));
}
return;
}
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download // file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
if (message.type === 'file_saved') { if (message.type === 'file_saved') {
const serverPath = (message.payload.serverPath as string) || ''; const serverPath = (message.payload.serverPath as string) || '';
@@ -749,8 +891,17 @@ const ChatScreen: React.FC = () => {
messageId: (message.payload.messageId as string) || undefined, messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined, backupTs: (message.payload.backupTs as number) || undefined,
}; };
return capMessages([...prev, ariaMsg]); // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m =>
m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' }
: m
);
}); });
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog();
} }
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
@@ -795,6 +946,21 @@ const ChatScreen: React.FC = () => {
setAgentActivity({ activity, tool }); setAgentActivity({ activity, tool });
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert // Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus). // nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
// activity-Event), Timer neu starten. 180s ohne Update → Hang.
clearStuckWatchdog();
if (activity !== 'idle') {
stuckWatchdog.current = setTimeout(() => {
stuckWatchdog.current = null;
setAgentActivity({ activity: 'idle', tool: '' });
setMessages(prev => capMessages([...prev, {
id: nextId(),
sender: 'aria',
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 3 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
timestamp: Date.now(),
}]));
}, 180_000);
}
} }
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -838,6 +1004,7 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => { const unsubState = rvs.onStateChange((state) => {
setConnectionState(state); setConnectionState(state);
connectionStateRef.current = state;
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die // Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation // Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline // zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
@@ -845,11 +1012,26 @@ const ChatScreen: React.FC = () => {
// Nachrichten vom Server, oder leeres Array wenn Server leer. // Nachrichten vom Server, oder leeres Array wenn Server leer.
if (state === 'connected') { if (state === 'connected') {
rvs.send('chat_history_request' as any, { since: 0, limit: 200 }); rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
// Offline-Queue flushen — alle 'queued'-Bubbles raussschicken
flushQueuedMessages();
} else if (state === 'disconnected') {
// ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck
for (const [cmid, t] of ackTimers.current.entries()) {
clearTimeout(t);
ackTimers.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus === 'sending'
? { ...m, deliveryStatus: 'queued' }
: m
));
}
} }
}); });
// Initalen Status setzen // Initalen Status setzen
setConnectionState(rvs.getState()); const initialState = rvs.getState();
setConnectionState(initialState);
connectionStateRef.current = initialState;
return () => { return () => {
unsubMessage(); unsubMessage();
@@ -1051,26 +1233,60 @@ const ChatScreen: React.FC = () => {
setSearchIndex(0); setSearchIndex(0);
}, [searchQuery]); }, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen. // Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render // neue Nachrichten kommen waehrend Suche aktiv ist invertedMessages
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal // aendert sich, soll aber nicht den Scroll erneut triggern).
// damit Layout sicher fertig ist. const lastSearchScrollKey = useRef<string>('');
// Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPendingScrollRetry = () => {
if (pendingScrollRetry.current) {
clearTimeout(pendingScrollRetry.current);
pendingScrollRetry.current = null;
}
};
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
// FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar.
// WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das
// Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok.
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
const invertedMessagesRef = useRef(invertedMessages);
invertedMessagesRef.current = invertedMessages;
useEffect(() => { useEffect(() => {
if (!searchMatchIds.length) return; if (!searchMatchIds.length) {
lastSearchScrollKey.current = '';
clearPendingScrollRetry();
return;
}
const id = searchMatchIds[searchIndex]; const id = searchMatchIds[searchIndex];
if (!id) return; if (!id) return;
const idx = invertedMessages.findIndex(m => m.id === id); // Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische
// Re-Renders erneut scrollen.
const key = `${searchIndex}:${id}`;
if (lastSearchScrollKey.current === key) return;
lastSearchScrollKey.current = key;
// Neue Suche → alte Retries verwerfen
clearPendingScrollRetry();
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return; if (idx < 0 || !flatListRef.current) return;
const tryScroll = () => { requestAnimationFrame(() => {
try { try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
} catch { } catch {
// wird von onScrollToIndexFailed nochmal versucht // onScrollToIndexFailed-Handler uebernimmt den Fallback
} }
}; });
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame }, [searchIndex, searchMatchIds]);
requestAnimationFrame(tryScroll);
}, [searchIndex, searchMatchIds, invertedMessages]); // Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
useEffect(() => () => {
clearPendingScrollRetry();
clearStuckWatchdog();
}, []);
const activeSearchId = searchMatchIds[searchIndex] || ''; const activeSearchId = searchMatchIds[searchIndex] || '';
const gotoSearchPrev = () => { const gotoSearchPrev = () => {
@@ -1150,29 +1366,33 @@ const ChatScreen: React.FC = () => {
const wasInterrupted = interruptAriaIfBusy(); const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text, text,
timestamp: Date.now(), timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s', console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted); cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort) dispatchWithAck(cmid, 'chat', {
rvs.send('chat', {
text, text,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
interrupted: wasInterrupted, interrupted: wasInterrupted,
...(location && { location }), ...(location && { location }),
}); });
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]); }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix // Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => { const cancelRequest = useCallback(() => {
setAgentActivity({ activity: 'idle', tool: '' }); setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {}); rvs.send('cancel_request' as any, {});
}, []); }, []);
@@ -1189,6 +1409,7 @@ const ChatScreen: React.FC = () => {
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)'); if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) { if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' }); setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {}); rvs.send('cancel_request' as any, {});
} }
return true; return true;
@@ -1201,16 +1422,20 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const cmid = nextClientMsgId();
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...', text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(), timestamp: Date.now(),
audioRequestId, audioRequestId,
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', { dispatchWithAck(cmid, 'audio', {
base64: result.base64, base64: result.base64,
durationMs: result.durationMs, durationMs: result.durationMs,
mimeType: result.mimeType, mimeType: result.mimeType,
@@ -1271,13 +1496,20 @@ const ChatScreen: React.FC = () => {
}); });
} }
// Chat-Nachricht mit allen Anhaengen // Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
const cmid = messageText ? nextClientMsgId() : undefined;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: msgId, id: msgId,
sender: 'user', sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`, text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(), timestamp: Date.now(),
attachments, attachments,
...(cmid && {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}),
}; };
setMessages(prev => capMessages([...prev, userMsg])); setMessages(prev => capMessages([...prev, userMsg]));
@@ -1311,9 +1543,11 @@ const ChatScreen: React.FC = () => {
}); });
} }
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) // Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
if (messageText) { // dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
rvs.send('chat', { // adressieren.
if (messageText && cmid) {
dispatchWithAck(cmid, 'chat', {
text: messageText, text: messageText,
voice: localXttsVoiceRef.current, voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current, speed: ttsSpeedRef.current,
@@ -1323,7 +1557,7 @@ const ChatScreen: React.FC = () => {
setPendingAttachments([]); setPendingAttachments([]);
setInputText(''); setInputText('');
}, [pendingAttachments, getCurrentLocation]); }, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// --- Rendering --- // --- Rendering ---
@@ -1375,17 +1609,30 @@ const ChatScreen: React.FC = () => {
<TouchableOpacity <TouchableOpacity
key={`${item.id}-att-${idx}`} key={`${item.id}-att-${idx}`}
style={styles.memoryAttachmentRow} style={styles.memoryAttachmentRow}
onPress={() => { onPress={async () => {
if (!a.path) return; if (!a.path) return;
if (a.localUri) { if (a.localUri) {
if (isImage) setFullscreenImage(a.localUri); const localPath = a.localUri.replace(/^file:\/\//, '');
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || ''); const exists = await RNFS.exists(localPath).catch(() => false);
} else { if (exists) {
// Datei via Bridge nachladen — file_response hat den if (isImage) setFullscreenImage(a.localUri);
// memorySaved-Match-Path und cached + zeigt direkt else openFileWithIntent(localPath, a.mime || '');
autoOpenPaths.current.add(a.path); return;
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` }); }
// Cache weg → localUri leeren + neu laden
setMessages(prev => prev.map(mm => mm.id === item.id && mm.memorySaved
? { ...mm, memorySaved: { ...mm.memorySaved,
attachments: mm.memorySaved.attachments?.map(x =>
x.path === a.path ? { ...x, localUri: undefined } : x) } }
: mm));
if (Platform.OS === 'android') {
ToastAndroid.show('Cache leer — lade nach...', ToastAndroid.SHORT);
}
} }
// Datei via Bridge nachladen — file_response hat den
// memorySaved-Match-Path und cached + zeigt direkt
autoOpenPaths.current.add(a.path);
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
}} }}
> >
<Text style={styles.memoryAttachmentIcon}>{icon}</Text> <Text style={styles.memoryAttachmentIcon}>{icon}</Text>
@@ -1489,17 +1736,32 @@ const ChatScreen: React.FC = () => {
) : ( ) : (
<TouchableOpacity <TouchableOpacity
style={styles.attachmentFile} style={styles.attachmentFile}
onPress={() => { onPress={async () => {
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen // Lokal vorhanden? Cache koennte geleert worden sein \u2014
// Datei-Existenz pruefen bevor wir den Intent feuern.
if (att.uri) { if (att.uri) {
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || ''); const localPath = att.uri.replace(/^file:\/\//, '');
return; const exists = await RNFS.exists(localPath).catch(() => false);
if (exists) {
openFileWithIntent(localPath, att.mimeType || '');
return;
}
// Cache weg \u2192 uri im State leeren damit UI "tippen zum Laden" zeigt
setMessages(prev => prev.map(m => m.id === item.id
? { ...m, attachments: m.attachments?.map(a =>
a.serverPath === att.serverPath ? { ...a, uri: undefined } : a) }
: m));
if (Platform.OS === 'android') {
ToastAndroid.show('Cache leer \u2014 lade nach...', ToastAndroid.SHORT);
}
} }
// Sonst: file_request \u2192 bei file_response wird die Datei // Re-Download via file_request \u2192 bei file_response wird die
// gespeichert UND geoeffnet (autoOpenPaths-Tracking). // Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) { if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath); autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id }); rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
} else if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
} }
}} }}
> >
@@ -1562,7 +1824,31 @@ const ChatScreen: React.FC = () => {
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text> <Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
<Text style={styles.timestamp}>{time}</Text> <View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text>
{isUser && item.deliveryStatus ? (
item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
>
<Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
</TouchableOpacity>
) : (
<Text style={
item.deliveryStatus === 'queued' ? styles.statusQueued :
item.deliveryStatus === 'sending' ? styles.statusSending :
item.deliveryStatus === 'sent' ? styles.statusSent :
/* delivered */ styles.statusDelivered
}>
{item.deliveryStatus === 'queued' ? '⏱' :
item.deliveryStatus === 'sending' ? '⏳' :
item.deliveryStatus === 'sent' ? '✓' :
/* delivered */ '✓✓'}
</Text>
)
) : null}
</View>
</View> </View>
); );
}; };
@@ -1698,15 +1984,26 @@ const ChatScreen: React.FC = () => {
ref={flatListRef} ref={flatListRef}
inverted inverted
data={invertedMessages} data={invertedMessages}
onScroll={(e) => {
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
const y = e.nativeEvent.contentOffset.y;
setShowJumpDown(y > 250);
}}
scrollEventThrottle={120}
onScrollToIndexFailed={(info) => { onScrollToIndexFailed={(info) => {
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die // FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms // die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
// praezise nochmal versuchen. // nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
// Scrolls in der Pipeline = der "permanent springen"-Bug).
const offset = info.averageItemLength * info.index; const offset = info.averageItemLength * info.index;
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {} try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
setTimeout(() => { clearPendingScrollRetry();
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {} pendingScrollRetry.current = setTimeout(() => {
}, 250); pendingScrollRetry.current = null;
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
}, 300);
}} }}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={renderMessage} renderItem={renderMessage}
@@ -1773,6 +2070,24 @@ const ChatScreen: React.FC = () => {
</View> </View>
)} )}
{/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten
Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset
0 == neueste Nachricht visuell unten. */}
{showJumpDown && (
<TouchableOpacity
style={styles.jumpDownBtn}
activeOpacity={0.85}
onPress={() => {
try {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
} catch {}
setShowJumpDown(false);
}}
>
<Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
</TouchableOpacity>
)}
{/* Eingabebereich */} {/* Eingabebereich */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
{/* Datei-Buttons */} {/* Datei-Buttons */}
@@ -2111,6 +2426,35 @@ const styles = StyleSheet.create({
marginTop: 4, marginTop: 4,
alignSelf: 'flex-end', alignSelf: 'flex-end',
}, },
statusRow: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-end',
gap: 6,
marginTop: 4,
},
statusQueued: {
color: '#FFD60A', // Gelb — wartet auf Verbindung
fontSize: 11,
},
statusSending: {
color: 'rgba(255,255,255,0.5)',
fontSize: 11,
},
statusSent: {
color: 'rgba(255,255,255,0.6)',
fontSize: 12,
},
statusDelivered: {
color: '#34C759', // Gruen — Brain hat geantwortet
fontSize: 12,
fontWeight: '700',
},
statusFailed: {
color: '#FF3B30',
fontSize: 11,
fontWeight: '700',
},
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -2313,6 +2657,23 @@ const styles = StyleSheet.create({
color: '#555570', color: '#555570',
fontSize: 10, fontSize: 10,
}, },
jumpDownBtn: {
position: 'absolute',
right: 16,
bottom: 80,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#0096FF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
elevation: 5,
zIndex: 100,
},
bubbleTrash: { bubbleTrash: {
position: 'absolute', position: 'absolute',
top: 4, top: 4,
+1 -1
View File
@@ -868,7 +868,7 @@ const SettingsScreen: React.FC = () => {
})()} })()}
</View> </View>
</Modal> </Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
{currentSection === null && ( {currentSection === null && (
<> <>
+29 -15
View File
@@ -54,6 +54,18 @@ function _newRequestId(): string {
return `brain_${Date.now().toString(36)}_${_nextId}`; return `brain_${Date.now().toString(36)}_${_nextId}`;
} }
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
function _qs(params: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
interface SendOpts { interface SendOpts {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: AnyJson; body?: AnyJson;
@@ -119,29 +131,31 @@ export const brainApi = {
/** Liste aller Memories, optional nach Type gefiltert. */ /** Liste aller Memories, optional nach Type gefiltert. */
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> { listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams(); const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
if (opts.type) qs.set('type', opts.type); return _send(`/memory/list${qs}`);
qs.set('limit', String(opts.limit || 500));
return _send(`/memory/list?${qs.toString()}`);
}, },
/** Volltext-Substring-Suche. */ /** Volltext-Substring-Suche. */
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> { searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q }); const qs = _qs({
if (opts.type) qs.set('type', opts.type); q,
qs.set('include_pinned', String(opts.includePinned !== false)); type: opts.type,
qs.set('k', String(opts.k || 50)); include_pinned: opts.includePinned !== false,
return _send(`/memory/search-text?${qs.toString()}`); k: opts.k || 50,
});
return _send(`/memory/search-text${qs}`);
}, },
/** Semantische Suche (Embedder). */ /** Semantische Suche (Embedder). */
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> { searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q }); const qs = _qs({
if (opts.type) qs.set('type', opts.type); q,
qs.set('include_pinned', String(opts.includePinned !== false)); type: opts.type,
qs.set('k', String(opts.k || 10)); include_pinned: opts.includePinned !== false,
qs.set('score_threshold', String(opts.threshold ?? 0.30)); k: opts.k || 10,
return _send(`/memory/search?${qs.toString()}`); score_threshold: opts.threshold ?? 0.30,
});
return _send(`/memory/search${qs}`);
}, },
/** Memory anlegen. */ /** Memory anlegen. */
+11 -2
View File
@@ -134,10 +134,19 @@ META_TOOLS = [
"function": { "function": {
"name": "trigger_watcher", "name": "trigger_watcher",
"description": ( "description": (
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, " "Lege einen Watcher-Trigger an — pollt eine Condition, "
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). " "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'. " "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." "Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
"nur gegen GPS-Jitter).\n"
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
+68 -19
View File
@@ -27,7 +27,12 @@ import watcher as watcher_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TICK_SEC = 30 # Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
TICK_SEC = 8
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
async def _tick(agent_factory) -> None: async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.""" """Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
near()-State-Tracking: entered_near/left_near brauchen die Information
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
Wir halten das pro Trigger als near_states-Dict im Manifest und
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
try: try:
all_triggers = triggers_mod.list_triggers(active_only=True) all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e: except Exception as e:
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
if not all_triggers: if not all_triggers:
return return
now = datetime.now(timezone.utc) 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: for trigger in all_triggers:
if trigger.get("type") != "watcher":
continue
try: try:
if _should_fire(trigger, vars_, now): # Variablen pro Trigger sammeln — wegen prev_near_states-Closure
prev = trigger.get("near_states") or {}
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
fired = _should_fire(trigger, vars_, now)
# State immer updaten, egal ob gefeuert wurde — sonst greift
# entered_near/left_near nicht
new_states = vars_.get("_new_near_states") or {}
trigger["near_states"] = new_states
trigger["last_checked_at"] = _now_iso()
try:
triggers_mod.write(trigger["name"], trigger)
except Exception as e:
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
if fired:
# Feuern als eigener Task — wenn ARIA langsam antwortet, # Feuern als eigener Task — wenn ARIA langsam antwortet,
# darf der naechste Tick nicht blockieren # darf der naechste Tick nicht blockieren
asyncio.create_task(_fire(trigger, agent_factory)) asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e: except Exception as e:
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e) logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
# Timer (one-shot) — separat ohne near-State
timer_vars = None
for trigger in all_triggers:
if trigger.get("type") != "timer":
continue
try:
if timer_vars is None:
timer_vars = watcher_mod.collect_variables()
if _should_fire(trigger, timer_vars, now):
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
# Lifespan-Pfad geschleust zu werden.
_AGENT_FACTORY = None
async def tick_now() -> dict:
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
_persist_location, damit Watcher mit near() den frischen Wert sofort
sehen statt bis zu TICK_SEC Sekunden zu warten."""
if _AGENT_FACTORY is None:
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
try:
await _tick(_AGENT_FACTORY)
return {"ok": True}
except Exception as exc:
logger.exception("tick_now: %s", exc)
return {"ok": False, "error": str(exc)}
async def run_loop(agent_factory) -> None: async def run_loop(agent_factory) -> None:
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt.""" """Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
global _AGENT_FACTORY
_AGENT_FACTORY = agent_factory
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC) logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
while True: while True:
try: try:
+10
View File
@@ -657,6 +657,16 @@ def triggers_list(active_only: bool = False):
return {"triggers": triggers_mod.list_triggers(active_only=active_only)} return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
@app.post("/triggers/check-now")
async def triggers_check_now():
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
zu warten. Wird von der Bridge nach jedem location_update gerufen
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
return await background_mod.tick_now()
@app.get("/triggers/conditions") @app.get("/triggers/conditions")
def triggers_conditions(): def triggers_conditions():
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions """Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
+81 -7
View File
@@ -25,7 +25,7 @@ import shutil
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] _DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht
# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt
# die Position als "unbekannt" und near() liefert False — verhindert
# Phantom-Fires basierend auf einer wochen-alten Position.
NEAR_MAX_AGE_SEC = 5 * 60
def _gps_state() -> dict[str, Any]: def _gps_state() -> dict[str, Any]:
"""Letzte bekannte Position aus /shared/state/location.json. """Letzte bekannte Position aus /shared/state/location.json.
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
return int(time.time() - ts) return int(time.time() - ts)
def collect_variables() -> dict[str, Any]: def _near_key(lat: float, lon: float, radius_m: float) -> str:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.""" """Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
State-Tracking pro Trigger pro Aufrufstelle."""
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
(für entered_near/left_near). Wird vom background-Loop reingegeben.
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
if prev_near_states is None:
prev_near_states = {}
new_near_states: Dict[str, bool] = {}
free_gb, free_pct = _disk_stats() free_gb, free_pct = _disk_stats()
now = datetime.now() now = datetime.now()
gps = _gps_state() gps = _gps_state()
@@ -176,12 +196,17 @@ def collect_variables() -> dict[str, Any]:
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt. # Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht. # Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
def _near(lat: float, lon: float, radius_m: float) -> bool: def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.""" """Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
veraltet betrachtet → False."""
cur_lat = vars_.get("current_lat") cur_lat = vars_.get("current_lat")
cur_lon = vars_.get("current_lon") cur_lon = vars_.get("current_lon")
if cur_lat is None or cur_lon is None: if cur_lat is None or cur_lon is None:
return False return False
age = vars_.get("location_age_sec")
if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC:
return False
try: try:
R = 6371000.0 R = 6371000.0
phi1 = math.radians(float(cur_lat)) phi1 = math.radians(float(cur_lat))
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
except Exception: except Exception:
return False return False
def _near(lat: float, lon: float, radius_m: float) -> bool:
"""True solange im Radius drin. Plus State-Tracking fuer
entered_near/left_near — wir merken uns das letzte Ergebnis
damit Uebergaenge erkannt werden koennen."""
current = _compute_near(lat, lon, radius_m)
new_near_states[_near_key(lat, lon, radius_m)] = current
return current
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return current and not prev
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
du am Parkplatz X was vergessen?' beim Verlassen."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return prev and not current
vars_["near"] = _near vars_["near"] = _near
vars_["entered_near"] = _entered_near
vars_["left_near"] = _left_near
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
vars_["_new_near_states"] = new_near_states
return vars_ return vars_
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
{ {
"name": "near", "name": "near",
"signature": "near(lat, lon, radius_m)", "signature": "near(lat, lon, radius_m)",
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern " "desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.", "Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
"Use-Case: 'bin noch in der Naehe von X?'. "
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
},
{
"name": "entered_near",
"signature": "entered_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
"Vorwarnung bevor man am Punkt ist.",
},
{
"name": "left_near",
"signature": "left_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
"vergessen?' beim Wegfahren.",
}, },
] ]
+79 -1
View File
@@ -25,6 +25,7 @@ import time
import sys import sys
import tempfile import tempfile
import uuid import uuid
from collections import OrderedDict
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -475,6 +476,13 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode() self.current_mode = self._load_persisted_mode()
self.running = False self.running = False
# Idempotenz: zuletzt gesehene clientMsgIds (App-seitig generiert).
# Beim Reconnect/Retry sendet die App dieselbe ID nochmal — wir
# antworten erneut mit ACK aber leiten NICHT doppelt an Brain weiter.
# OrderedDict als FIFO mit Capping (Insertion-Order).
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
self._SEEN_CLIENT_MSG_LIMIT = 200
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt) # Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
self.tts_enabled = True self.tts_enabled = True
self.xtts_voice = "" self.xtts_voice = ""
@@ -938,7 +946,12 @@ class ARIABridge:
def _persist_location(self, location: Optional[dict]) -> None: def _persist_location(self, location: Optional[dict]) -> None:
"""Speichert die letzte bekannte GPS-Position fuer Watcher. """Speichert die letzte bekannte GPS-Position fuer Watcher.
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
Koordinaten werden ignoriert.""" Koordinaten werden ignoriert.
Plus: triggert sofort einen on-demand Trigger-Check im Brain
(POST /triggers/check-now). Ohne das wartet der Watcher-Loop
bis zu TICK_SEC Sekunden — bei Auto-Vorbeifahrt durch einen
300m-Radius (18-43s drin) kann das den Trigger verpassen."""
if not isinstance(location, dict): if not isinstance(location, dict):
return return
try: try:
@@ -950,9 +963,31 @@ class ARIABridge:
"lat": float(lat), "lat": float(lat),
"lon": float(lon), "lon": float(lon),
}) })
except Exception:
return
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
# oder langsam ist, blockt das nicht den GPS-Pfad.
try:
asyncio.create_task(self._trigger_brain_check_now())
except Exception: except Exception:
pass pass
async def _trigger_brain_check_now(self) -> None:
"""Brain-Endpoint POST /triggers/check-now anstossen."""
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
def _post():
try:
req = urllib.request.Request(
f"{brain_url}/triggers/check-now",
data=b"", method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=8) as r:
return r.status
except Exception:
return None
await asyncio.get_event_loop().run_in_executor(None, _post)
def _persist_user_activity(self) -> None: def _persist_user_activity(self) -> None:
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice). """Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
Watcher: last_user_message_ago_sec basiert darauf.""" Watcher: last_user_message_ago_sec basiert darauf."""
@@ -1503,6 +1538,36 @@ class ARIABridge:
except Exception: except Exception:
break break
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
App nach Timeout retryen — gegen Verlust bei Netz-Hicksern.
"""
if not client_msg_id:
return
await self._send_to_rvs({
"type": "chat_ack",
"payload": {"clientMsgId": client_msg_id},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
def _is_duplicate_client_msg(self, client_msg_id: Optional[str]) -> bool:
"""Prueft ob wir diese clientMsgId schon verarbeitet haben.
Wenn ja → True (Caller soll ACK senden aber NICHT an Brain forwarden).
Wenn nein → in den Seen-Cache aufnehmen + False zurueck.
"""
if not client_msg_id:
return False
if client_msg_id in self._seen_client_msg_ids:
logger.info("[rvs] Idempotenz: cmid=%s bereits verarbeitet, ignoriere",
client_msg_id)
return True
self._seen_client_msg_ids[client_msg_id] = time.time()
# Capping: aelteste Eintraege rauswerfen
while len(self._seen_client_msg_ids) > self._SEEN_CLIENT_MSG_LIMIT:
self._seen_client_msg_ids.popitem(last=False)
return False
async def _handle_rvs_message(self, raw_message: str) -> None: async def _handle_rvs_message(self, raw_message: str) -> None:
"""Verarbeitet Nachrichten von der App (via RVS). """Verarbeitet Nachrichten von der App (via RVS).
@@ -1527,6 +1592,13 @@ class ARIABridge:
sender = payload.get("sender", "") sender = payload.get("sender", "")
if sender in ("aria", "stt"): if sender in ("aria", "stt"):
return return
# Delivery-ACK: immer zurueckschicken (auch bei Idempotenz-Hit),
# damit die App den Status auf 'sent' setzen kann. Idempotenz-
# Check VERHINDERT aber die Doppel-Weiterleitung an Brain.
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
text = payload.get("text", "") text = payload.get("text", "")
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten # Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen). # chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
@@ -2126,6 +2198,12 @@ class ARIABridge:
elif msg_type == "audio": elif msg_type == "audio":
# Audio von der App → decodieren → STT → an aria-core # Audio von der App → decodieren → STT → an aria-core
# Delivery-ACK + Idempotenz wie bei chat — App nutzt die ACKs
# auch fuer Sprach-Bubbles (Status auf der Bubble: ✓ sent).
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
audio_b64 = payload.get("base64", "") audio_b64 = payload.get("base64", "")
mime_type = payload.get("mimeType", "audio/mp4") mime_type = payload.get("mimeType", "audio/mp4")
duration_ms = payload.get("durationMs", 0) duration_ms = payload.get("durationMs", 0)
+17 -1
View File
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage - [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, -Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind - [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, -Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
### GPS-Trigger-Verbesserungen (entered_near + left_near + Timing-Fix)
- [x] **near() bei Auto-Vorbeifahrten verpasst — gefixt**: Background-Loop tickte alle 30s, Vorbeifahrt durch 300m-Radius bei 50-120 km/h dauert nur 18-43s → Tick konnte komplett dazwischen liegen. Fix: `TICK_SEC` 30 → 8 (Loop ist billig, Brain merkt das nicht). Plus event-getrieben: Bridge ruft nach jedem `location_update` ein POST `/triggers/check-now` im Brain → Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. Polling läuft parallel als Fallback für Watcher ohne GPS-Bezug
- [x] **near() Age-Schutz**: GPS-Daten älter als 5 Minuten (`NEAR_MAX_AGE_SEC=300`) gelten als veraltet → `near()` liefert False. Vorher hätte ein wochen-alter Wert die Funktion weiter als „in der Nähe" eingeordnet → Phantom-Fires wenn Tracking aus war
- [x] **Drei GPS-Modi statt einem**: `near()` bleibt = „solange drin". Neu: **`entered_near(lat, lon, r)`** feuert NUR beim Übergang außen→innen (Blitzer-Warner mit r=2000 = 2 km Vorwarnung, Ankunft mit r=100), **`left_near(lat, lon, r)`** feuert NUR beim Übergang innen→außen („Hast du am Parkplatz was vergessen?"). State-Tracking pro Trigger pro near-Aufruf (`near_states`-Dict im Manifest) — Background-Loop schreibt den letzten Auswertungswert immer zurück, damit beim nächsten Tick die Übergangs-Erkennung greift. ARIA's `trigger_watcher`-Tool-Description erklärt die drei Modi inkl. empfohlener Throttle-Werte (kurz für entered/left, lang für near)
### App-Memory-Editor + Crash-Reporting
- [x] **Bubble-Header dynamic** (created/updated/deleted): Die `🧠`-Bubble zeigt jetzt was passiert ist — "ARIA hat etwas gemerkt" / "Notiz geändert" / "Notiz gelöscht" (rot bei delete). Brain-Tools schicken `action`-Feld im memory_saved-Event mit
- [x] **Tap auf Memory-Bubble → Detail-Modal**: Komponente `MemoryDetailModal` zeigt alle Felder (Titel, Type, Category, Tags, voller Content, Anhang-Vorschau mit Thumbnails). Stift-Icon wechselt in Edit-Mode mit Form-Feldern + 📌 Pinned-Toggle. **Anhänge hoch-/runterladen + löschen** im Modal (DocumentPicker, multipart-Upload via RVS-Brain-Proxy). Memory komplett löschen mit Confirm
- [x] **Notizen-Inbox-Button (`🗂️`)** neben der Lupe in der Status-Leiste: Vollbild-Modal mit zwei Sections — „Aus diesem Chat" (kompakte Liste der Spezial-Bubbles aus dem aktuellen Verlauf, klickbar) + „Alle Memories aus der DB" mit dem `MemoryBrowser`. Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) werden im Chat-Stream gefiltert (statt unten zu kleben)
- [x] **Memory-Editor in App-Settings**: neue Sektion 🧠 „Gedächtnis" in den App-Einstellungen. Komplette CRUD-UI mit Wortlich-Suche, Type-Dropdown, Pinned/Cold-Filter, „+ Neu" anlegen. Selbe `MemoryBrowser`-Komponente wie in der Inbox
- [x] **RVS-Brain-Proxy als Fundament**: Bridge implementiert generischen `brain_request` / `brain_response`-Channel — die App kann beliebige Brain-HTTP-Endpoints via RVS adressieren (GET/POST/PATCH/DELETE, JSON+Base64-Body, base64-encoded Binär-Antworten). `services/brainApi.ts` als Promise-basierter Client mit Request-ID-Routing, Timeout, automatischem Listener-Setup
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary-Komponente fängt React-Render-Fehler, `installGlobalCrashReporter` haengt sich an `ErrorUtils.setGlobalHandler` + `HermesInternal.enablePromiseRejectionTracker`. Crashes wandern als `app_log`-Event durch RVS, Bridge schreibt JSONL in `/shared/logs/app.log`. Diagnostic-Server liefert GET `/api/app-log[?limit=N]` + POST `/api/app-log/clear`. **`tools/fetch-app-logs.sh`** holt die Logs auf die Dev-Maschine (über `ARIA_DIAG_URL` aus `.claude/aria-vm.env`), speichert in `.aria-debug/` (gitignored), zeigt Stack-Trace kompakt auf stdout
- [x] **`memory_search` + `memory_update` Tools**: ARIA kann die DB jetzt aktiv durchsuchen (Volltext/Semantic) und existierende Einträge per ID patchen statt fragmentierende neue anzulegen. Tool-Description sagt explizit „Memory ist Truth über Conversation-Window" — wenn der User korrigiert hat, gilt das was im Memory steht. Wichtig nach Diagnostic-Edits damit ARIA die neue Wahrheit sieht statt aus dem Window zu raten
- [x] **App-Bugfixes**: (a) URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt (`brainApi._qs()`). (b) Cache leer + Datei-Tap → Auto-Re-Download via file_request statt Toast-Sackgasse, plus State-Cleanup (uri/localUri auf undefined). (c) Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled auf FlatList + äußere ScrollView). (d) Modal-im-Modal auf Android gefixt — MemoryBrowser nimmt optionalen `onOpenMemory`-Callback, kein verschachteltes DetailModal mehr. (e) Alert.prompt (iOS-only) durch eigenes Text-Input-Modal ersetzt fuer „Neue Memory anlegen"
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths) ### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei - [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
@@ -331,7 +348,6 @@ Skills mit Tool-Use.
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild) - [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur ### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU) - [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen - [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push - [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push