Compare commits

...

45 Commits

Author SHA1 Message Date
duffyduck 22adc91c1e release: bump version to 0.1.5.1 2026-05-15 12:12:17 +02:00
duffyduck 61cf8e3bcc fix(chat): Such-Sprung beim ersten Versuch nach App-Start
Symptom: Suchbegriff direkt nach App-Start eingegeben → springt an
falsche Stelle. Erst beim zweiten Versuch funktioniert es.

Ursache: FlatList rendert per Default nur 10 Items initial.
info.averageItemLength im onScrollToIndexFailed basiert nur auf diesen
10 — bei einem Suchtreffer auf Bubble 150 ist die Schaetzung katastrophal
falsch. Beim zweiten Versuch ist die FlatList „warm gelaufen" und mehr
Items sind gemessen → Schaetzung passt besser.

Drei kombinierte Fixes:

1) Pre-Scroll: vor dem scrollToIndex erst grob mit AVG_BUBBLE_HEIGHT=150
   per scrollToOffset(idx*150) in die Naehe springen. FlatList rendert
   die Bubbles in der Naehe, dann praezise nachsetzen nach 80ms.

2) initialNumToRender=30 (Default 10) — mehr Items beim Mount gemessen.

3) windowSize=41 (Default 21) — mehr Items im Speicher gehalten, weniger
   Layout-Holes beim Weit-Scroll.

Kosten: minimal hoehere Mount-Zeit. Bei 300+ Bubbles im Backup macht
sich der UX-Gewinn lohnt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:10:13 +02:00
duffyduck 3e38f1dad3 release: bump version to 0.1.5.0 2026-05-15 12:03:39 +02:00
duffyduck 635944299e fix(chat): Such-Scroll springt nicht mehr endlos (Retry-Limit + Skip)
Symptom: Suche zeigt Treffer, springt aber permanent zwischen Bubbles
hin und her in Endlosschleife.

Zwei Ursachen, beide angeschlossen:

1) agent_activity-Handler rief setMessages mit prev.map() — auch wenn
   keine sending-Bubble da war. Das erzeugte trotzdem ein neues Array
   bei jedem Tool-Event (5-10x pro Brain-Call). invertedMessages neu →
   FlatList-Layouts invalidiert mitten in einer aktiven Scroll-Sequenz.
   Fix: prev.some() vor map() — wenn nichts zu aendern ist, prev
   unveraendert returnen (reference-stable, kein Re-Render).

2) onScrollToIndexFailed retried unbegrenzt. Jeder failed Retry rief
   den Handler erneut auf → neuer setTimeout → neuer Versuch → fail →
   loop. Vorher waren cascading 3 Retries, dann auf 1 reduziert um
   den 3-9-27-Cascade zu fixen, aber EIN ungebremster Retry-Schluss
   pro fail bleibt eine Endlos-Schleife wenn Layouts nie stabil
   werden. Fix: harter Counter (MAX_SCROLL_RETRIES = 3). Counter wird
   bei jedem neuen Search-Hit via clearPendingScrollRetry resettet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:58:54 +02:00
duffyduck b2ac013765 docs: heutige Brain-/Chat-Fixes + Gedanken-Stream
issue.md — drei neue Eintraege im Chat-Stabilitaet-Block:
- chat_backup ts auf UNIX-ms umgestellt + Migration
- User-Bubble →failed durch agent_activity-impliziten ACK gefixt
- Gedanken-Stream Modal scrollte nicht — Touchable→View+responder
Neuer Block 'Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-
Aggressivitaet' mit den drei Brain-Hang-Fixes.
Neuer Block 'Gedanken-Stream + Live-Tool-Events'.

README.md:
- Feature-Liste der App ergaenzt um Gedanken-Stream
- Diagnostic Main-Tab ergaenzt um 💭 Gedanken-Stream Modal
- Proxy-Sektion: dritter sed-Patch (DEFAULT_TIMEOUT 5→20 Min) +
  routes.js-Patch (tool_use-Hook) dokumentiert
- Brain↔Bridge ist async-Hinweis (send_to_core als create_task)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:51:57 +02:00
duffyduck 93db6a3156 fix(chat): Gedanken-Stream Modal scrollt jetzt
Der innere TouchableOpacity (eigentlich nur da um Tap-Propagation an
das aeussere close-on-tap-outside-Wrapper zu blocken) hat alle Touch-
Events konsumiert — FlatList bekam nichts ab, kein Scroll moeglich.

Fix: inner durch View ersetzen, mit onStartShouldSetResponder=true
plus onResponderTerminationRequest=false. Das blockt die Propagation
ohne Scrolls der Children zu verschlucken.

Close-on-Tap-outside funktioniert weiter (aeusseres TouchableOpacity
bleibt), das X im Header schliesst auch, Hardware-Back ebenfalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:48:39 +02:00
duffyduck 579a466402 release: bump version to 0.1.4.9 2026-05-15 11:45:53 +02:00
duffyduck 5133f0bc2d fix(chat): User-Bubble →failed bei langsamen ARIA-Antworten
Symptom: ARIA bearbeitet die Nachricht (im Gedanken-Stream sichtbar),
aber unter der User-Bubble bleibt die Sanduhr stehen und nach ~90 s
springt sie auf ⚠ failed. ARIA-Antwort kommt trotzdem irgendwann durch
— die Bubble war also nie weg, nur visuell schief.

Wurzel: chat_ack vom Bridge kam offenbar in manchen Faellen nicht
verlaesslich an. ACK-Timer (30 s × 3 Retries) lief durch → 'failed'.

Fix: agent_activity = thinking/tool/assistant ist impliziter Beweis,
dass das Brain die Nachricht bekommen und angefangen hat zu arbeiten.
Beim ersten non-idle Event:
- alle laufenden ACK-Timer cancelen
- alle 'sending'-User-Bubbles auf 'sent' (✓) setzen

ARIA-Reply markiert dann wie gehabt 'delivered' (✓✓). Damit kann keine
Bubble mehr auf failed gehen waehrend Brain noch laeuft.

Plus: ACK_TIMEOUT_MS 30 → 60 s als Backup-Reserve fuer den Fall dass
weder ACK noch agent_activity ankommt (sehr unwahrscheinlich, aber
billig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:43:04 +02:00
duffyduck a476a4b734 release: bump version to 0.1.4.8 2026-05-15 11:28:06 +02:00
duffyduck 11b205ddaf fix(chat): chat_backup ts auf UNIX-ms umgestellt + Doppelpost-Schutz
Bug-1: _append_chat_backup nutzte asyncio.get_event_loop().time() —
das ist Container-Monotonic (bei Restart wieder 0), NICHT UNIX-Zeit.
Bridge schrieb so Eintraege mit ts wie 394M (=6.5 min Uptime), App-side
generiert User-Bubbles mit Date.now() = 1.778e12. Beim Sortieren in
der App: Server-Bubbles landeten alle als "uralt" (kleine ts) ueber den
lokalen Bubbles und teilweise unter dem 500er-Cap raus — Symptom:
"alles nach Hello Kitty fehlt in der App".

Fix: _append_chat_backup nutzt jetzt time.time() * 1000 (UNIX-ms).

Bug-2: doppelte User-Bubble nach App-Hintergrund/Restart mit Retry-Knopf.
Race-Fix von vorhin (text+timestamp-Heuristik, 5-Min-Fenster) griff
nicht weil bei kaputten Server-ts (394M) und lokalen UNIX-ms (1.778e12)
das Diff 1.7 Billionen ms war → Fenster nie zutreffend → lokale Bubble
blieb als Duplikat.

Fix: Text-Match alleine reicht — wenn der Server irgendwo eine
textgleiche User-Bubble hat, ist es dieselbe Nachricht. Greift jetzt
unabhaengig von ts-Konsistenz.

Plus: tools/migrate_chat_backup_ts.py — repariert vorhandene jsonl
(284 von 299 Eintraege auf der VM hatten Container-Uptime-ts). Datei-
Reihenfolge bleibt erhalten (war eh chronologisch), ts werden ab File-
Mtime rueckwaerts 60s-Schritten vergeben. Idempotent, .bak-Backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:26:39 +02:00
duffyduck 71c60ade8a release: bump version to 0.1.4.7 2026-05-15 11:11:33 +02:00
duffyduck bf3dc635d9 feat(brain): Live-Tool-Events im Gedanken-Stream
Proxy-Patch hookt Claude-CLI `assistant`-Events: bei jedem tool_use-
Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge
gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an die
RVS-Clients. App- und Diagnostic-Gedanken-Stream zeigen damit live mit
was ARIA gerade macht — vorher kam pro Brain-Call nur EIN „💭 denkt"
am Anfang und EIN „✓ fertig" am Ende.

Drei neue Bausteine:
- proxy-patches/routes.js: kompletter Replacement der npm-Version mit
  `_attachToolHook(subprocess)` — feuert pro tool_use-Block ein HTTP-
  POST an http://aria-bridge:8090/internal/agent-activity (URL via
  ARIA_TOOL_HOOK_URL Env-Variable ueberschreibbar). Fire-and-forget,
  fail-open — Brain-Call bricht NICHT ab wenn Bridge mal nicht da ist.
- docker-compose.yml: vierter cp-Schritt im proxy-Service kopiert
  routes.js ueber die npm-Version (analog zu openai-to-cli + cli-to-
  openai).
- bridge/aria_bridge.py: neuer `/internal/agent-activity`-Endpoint im
  bestehenden _serve_internal_http. Plus _emit_activity hat jetzt
  force=True-Param damit wiederholte gleiche Tool-Aufrufe (3x Bash in
  Folge) als drei Eintraege im Stream sichtbar bleiben.

App + Diagnostic: pushThought-Dedup laesst tool-Events durch (3x Bash
hintereinander gibt 3 Eintraege im Gedanken-Stream).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:07:39 +02:00
duffyduck 8ca899aaf5 release: bump version to 0.1.4.6 2026-05-15 10:59:10 +02:00
duffyduck 15facf48eb fix(bridge): send_to_core als create_task — RVS-recv blockt nicht mehr
Live-Diagnose nach dem Timeout-Bump: Bridge-Brain-Call rennt jetzt zwar
20 Min — aber nach ~4 Min droppt der RVS-Server die WebSocket-Verbindung.
Symptom in App+Diagnostic: "denkt einfach abgebrochen".

Ursache: `async for raw_message in ws: await _handle_rvs_message(...)` —
das await blockt den recv-Loop solange send_to_core laeuft (bis zu 20
Min). Der mobil.hacker-net.de:444 RVS-Server droppt Verbindungen ohne
echte App-Frames nach ~4 Min als idle-Timeout. Die websockets-Lib
beantwortet Pings im Hintergrund, aber das reicht offenbar nicht — der
Server zaehlt nur Application-Frames.

Fix: chat-Handler ruft send_to_core als asyncio.create_task statt await.
Brain laeuft im Hintergrund-Task, RVS-recv-Loop bleibt frei, neue
Messages werden weiter verarbeitet, Verbindung bleibt lebendig. Gleicher
Fix in _flush_pending_files_with_text und file-empty-Edge-Case.

Tradeoff: parallele Brain-Calls wenn der User waehrend einer laufenden
Antwort schnell mehrere Nachrichten schickt. Brain (FastAPI) verarbeitet
beide, conversation.jsonl koennte racen. App macht aber bereits Barge-In
via cancel_request bei Folge-Nachrichten — in der Praxis treffen sich
parallele Calls selten. Wenn doch Probleme: Bridge-Side asyncio.Lock um
send_to_core in einer Folge-Etappe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:50:46 +02:00
duffyduck 71fc90fcb8 fix(brain): Timeouts 5min → 20min — verkettete Timeouts haben lange
Multi-Tool-Sessions chronisch gekappt

Live-Diagnose auf der VM: drei verkettete 5-Min-Timeouts feuern bei
jedem laengeren Brain-Call exakt gleichzeitig:

  06:16:02  Brain → Proxy /v1/chat/completions
  06:20:53  Bridge kappt (4m51s, urlopen timeout=300)
  06:21:02  Brain bekommt HTTP 500 vom Proxy ('timed out after 300000ms')

Stefan's Karten-Rekonstruktion (curl gegen Nominatim/OSRM + viele Bash-
Tool-Calls + DB-Inserts) braucht locker 8–15 Min — alle Brain-Calls
ueber 5 Min sind reihenweise mit 'Brain-Fehler: timed out' verreckt,
auch wenn die Arbeit zu 80% durch war.

Drei Stellen patchen:
- bridge/aria_bridge.py: urlopen 300 → 1200 (20 Min)
- aria-brain/proxy_client.py: PROXY_TIMEOUT_SEC default 300 → 1200
- docker-compose.yml: dritter sed-Patch im proxy-Service
  setzt DEFAULT_TIMEOUT im claude-max-api-proxy von 300000 auf 1200000

Plus App-Watchdog: 180s → 1260s (21 Min, knapp ueber Brain-Timeout)
damit der lokale Stuck-Watchdog nicht waehrend legitimer langer
Sessions feuert. Echte Verbindungsabbrueche kappen vorher per WS-
Disconnect.

UX-Tradeoff bewusst akzeptiert: User sieht jetzt bis zu 20 Min nur
'ARIA denkt...' ohne Zwischen-Updates. Echte Loesung waere Streaming
oder async-Job-API (siehe Etappe B/C im Vorschlag) — das ist groesseres
Refactoring, hier reicht erst mal der Quick-Fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:40:26 +02:00
duffyduck 856701fb6f feat(chat): Gedanken-Stream (App + Diagnostic)
Persistentes chronologisches Log was ARIA intern macht — gefuettert aus
agent_activity-Events (thinking/tool/assistant/idle). Bleibt zwischen
Denk-Phasen stehen, neue Eintraege kommen unten dran, lange Pausen
werden mit Trennlinie + Minuten-Hint sichtbar gemacht.

App (ChatScreen.tsx):
- 💭-Icon in der Statusleiste neben 🗂️ und 🔍, zeigt Eintrags-Anzahl
- Bottom-Sheet (60% Hoehe) mit chronologischer Liste, Tap auf Hintergrund
  schliesst, 🗑-Confirm zum Leeren
- Persistierung in AsyncStorage (aria_thought_stream, capped 500)
- Dedup gegen direkt aufeinanderfolgende identische Events

Diagnostic (index.html):
- 💭 Gedanken-Button im Chat-Test-Header neben „Vollbild"
- Zentrales Modal (720px x 70vh), Live-Update wenn neue Eintraege kommen
  (autoscroll ans Ende), 🗑 Leeren-Button mit Confirm
- Persistierung in localStorage, gleiche cap/dedup-Logik wie App

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:31:55 +02:00
duffyduck 6037b62612 fix(brain): ARIA legt nicht mehr ungefragt Skills an
Prompt sagte 'Harte Regel — IMMER Skill anlegen wenn pip-Library
noetig'. ARIA hat das wortwoertlich genommen: bei einer einfachen
pdf-extract-Frage hat sie sofort skill_create gerufen → Brain blockiert
12 Min im venv+pip-Install-subprocess.run, App zeigt 'ARIA denkt',
Diagnostic emitted nach 5 Min Timeout idle, Stefan blieb stundenlang
ohne Antwort.

Neue Regel:
- Goldene Regel: NIE ungefragt Skills anlegen.
- Aufgabe zuerst inline loesen (Bash, direkter pip install, Workaround).
- Skill nur wenn Stefan EXPLIZIT sagt 'mach daraus einen Skill' /
  'leg den als Skill an'.
- Die vier Kriterien (wiederkehrend/nicht-trivial/parametrisierbar/
  wiederverwendbar) sind jetzt Checkliste NACH expliziter Anfrage —
  fehlt eines, soll ARIA nachfragen statt blind anzulegen.
- Begruendung steht jetzt im Prompt: Setup blockt Brain bis zu 12 Min.

Greift auf der VM ohne Re-Build, prompts.py wird beim Start geladen
(docker compose restart aria-brain reicht).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:14:33 +02:00
duffyduck 8f88cb0030 fix(chat): Doppel-Bubble nach Retry + verwaiste ACK-Timer + docs
Race nach Etappe-3-Reconnect-Fix: lokale failed-Bubble (mit clientMsgId)
und Server-Backup-Eintrag (ohne clientMsgId, aus alter Bridge-Version)
landeten beide im Merge → User sah Doppelpost: einmal ueber der
ARIA-Antwort (Server), einmal mit Retry-Knopf darunter (lokal). Plus
ACK-Timer konnte weiterlaufen obwohl die Bubble schon delivered war —
Retry pushte den Status zurueck auf sending und nach 30 s auf failed.

App:
- chat_history_response-Merge faellt zusaetzlich auf text+timestamp-
  Heuristik im 5-Min-Fenster zurueck wenn die Server-Bubble keine
  clientMsgId hat → lokale Kopie wird verworfen, kein Doppelpost
- messagesRef + dispatchWithAck prueft vor Send/Retry ob die Bubble
  bereits delivered ist → kein verspaetetes failed mehr
- ARIA-Reply cleart ALLE laufenden ACK-Timer (Bridge hat unsere
  Messages ja offensichtlich verarbeitet)

Docs:
- issue.md: neuer Block 'Chat-Stabilitaet' mit den drei Etappen +
  beiden Race-Fixes; AsyncStorage-Race-Punkt aus 'Offen' abgehakt
- README.md: Chat-Such-Zeile aktualisiert (highlight statt filter),
  Jump-to-Bottom + Delivery-Status-Bubbles dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:46:58 +02:00
duffyduck c224562423 release: bump version to 0.1.4.5 2026-05-14 23:38:45 +02:00
duffyduck 5c07aef526 fix(chat): Offline-Bubble verschwand nach Reconnect — clientMsgId-Dedup
Race-Bug nach Etappe 3: Beim Reconnect schickt die App parallel
chat_history_request und (via flushQueuedMessages) die offline gestaute
Nachricht. Die history_response kam an bevor die Bridge die Bubble in
chat_backup.jsonl geschrieben hatte → Server-Liste ohne unsere Bubble →
Merge ersetzte den lokalen Stand → Bubble weg (im Diagnostic war sie
gleich danach drin).

Bridge: _append_chat_backup nimmt clientMsgId mit auf. send_to_core
reicht sie als kwarg durch (chat- und audio-Pfad).

App: chat_history_response-Merge dedupt per clientMsgId. Lokale User-
Bubbles deren clientMsgId der Server noch nicht kennt bleiben erhalten
(localOnly-Filter erweitert). Server-User-Bubbles mit clientMsgId
kriegen deliveryStatus='delivered' damit das ✓✓ auch nach Reload sichtbar
bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:14:11 +02:00
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
duffyduck ff7c6333bb release: bump version to 0.1.3.6 2026-05-14 16:00:42 +02:00
duffyduck 2c85df3499 chore: tools/fetch-app-logs.sh — App-Crash-Logs von der VM holen
Stefan ist unterwegs, ADB nicht moeglich. Dieses Script ist die andere
Haelfte des Crash-Reportings (commit 21a315c hat die App-Seite + Bridge-
Endpoint gebaut):

Nutzung:
  tools/fetch-app-logs.sh                # 200 letzte Eintraege
  tools/fetch-app-logs.sh --limit 50
  tools/fetch-app-logs.sh --watch        # alle 5s pollen + Diff ausgeben
  tools/fetch-app-logs.sh --clear        # Log auf VM nach Abholen leeren

Liest $ARIA_DIAG_URL aus .claude/aria-vm.env, ruft GET /api/app-log.
Speichert komplette JSON-Response in .aria-debug/app-log-<ts>.json
(gitignored). Stdout zeigt kompakt: Uhrzeit, Level, Scope, Message,
erste 8 Stack-Frames pro Eintrag.

.gitignore: .aria-debug/ ist komplett ausgeschlossen (Crashes
koennen private Daten enthalten).

tools/README.md: kurze Doku des Workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:59:06 +02:00
duffyduck 6f11f28448 release: bump version to 0.1.3.5 2026-05-14 15:45:36 +02:00
duffyduck 21a315ca71 feat(debug): App-Crash-Reporting via RVS — Logs in der Diagnostic-UI
Stefan ist unterwegs, ADB-Zugriff nicht moeglich. Loesung: die App
loggt ihre eigenen Crashes via RVS, Bridge sammelt sie in
/shared/logs/app.log, Diagnostic-Server liefert sie als JSON.
Damit braucht's keinen ADB mehr — Crashes sind sofort vom Browser
(oder Claude per curl) lesbar.

Komponenten:

1. App components/ErrorBoundary.tsx
   - React-ErrorBoundary fuer kritische Sections
   - componentDidCatch → reportAppError (RVS-Send)
   - UI zeigt Error-Box statt White-Screen + Reset-Button

2. App services/logger.ts
   - reportAppError(scope, message, stack) → rvs.send('app_log', ...)
   - installGlobalCrashReporter() haengt sich an ErrorUtils.setGlobalHandler
     UND HermesInternal.enablePromiseRejectionTracker — fangt sowohl
     ungefangene Errors als auch unhandled Promise-Rejections
   - Konsole bleibt parallel aktiv (damit ADB im Dev-Build weiter
     was sieht)

3. App App.tsx: installGlobalCrashReporter() im useEffect zusammen
   mit initLogger.

4. App ChatScreen.tsx:
   - Inbox-Modal mit ErrorBoundary umschlossen (scope: InboxModal,
     onReset schliesst Modal)
   - MemoryDetailModal mit ErrorBoundary umschlossen
   - DetailModal wird nur noch konditional gerendert (memoryDetailId
     != null) statt immer visible-toggle — vermeidet potentielles
     Modal-Stacking-Problem

5. RVS server.js: ALLOWED_TYPES += "app_log"

6. Bridge aria_bridge.py:
   - elif msg_type == "app_log": haengt eine Zeile an
     /shared/logs/app.log (JSONL, jedes Item {ts, platform, level,
     scope, message, stack})
   - Plus log.info Hinweis fuer das normale Bridge-Log

7. Diagnostic server.js:
   - GET /api/app-log[?limit=N] → letzte N Eintraege als JSON
   - POST /api/app-log/clear → log-Datei loeschen

Workflow zum Debuggen des Inbox-Crashes:
  Stefan rebuilded App → drueckt Inbox → ErrorBoundary fangt den
  Crash (oder Global-Handler bei ungefangenem Error) → reportAppError
  → RVS → Bridge schreibt nach /shared/logs/app.log → Stefan
  oder Claude rufen GET /api/app-log auf → sehen Stacktrace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:42:55 +02:00
duffyduck d8b05082d6 release: bump version to 0.1.3.4 2026-05-14 14:58:47 +02:00
duffyduck de91073b2e release: bump version to 0.1.3.3 2026-05-14 14:08:03 +02:00
duffyduck e88b5f57bf fix(memory): Inbox-Crash auf Android — Modal-Stacking + Alert.prompt
Stefan: App crasht beim Tap auf Inbox-Button. Zwei Ursachen:

1. Modal-in-Modal-Stacking (Inbox-Modal enthielt MemoryBrowser, der
   wiederum ein MemoryDetailModal gerendered hat). Android Modal hat
   damit Probleme — der Native-Layer mag nur eine Modal-Instance
   gleichzeitig zuverlaessig.

2. MemoryBrowser nutzte Alert.prompt fuer "Neue Memory anlegen" —
   das ist iOS-only, Android wirft eine Warnung oder crasht.

Fix:
- MemoryBrowser bekommt optionalen onOpenMemory-Callback. Wenn der
  Parent diesen liefert, mounted MemoryBrowser KEIN eigenes
  DetailModal mehr. ChatScreen mountet das DetailModal nur einmal
  auf seiner Ebene; Inbox-Modal schliesst sich beim Tap und delegiert
  die ID an memoryDetailId-State. Damit ist immer maximal ein Modal
  aktiv.
- Alert.prompt durch eigenes kleines Dialog-Modal ersetzt: TextInput
  fuer Titel, Anlegen/Abbrechen-Buttons. Cross-platform stabil.

SettingsScreen-Nutzung von MemoryBrowser bleibt unveraendert (kein
Callback → eingebautes DetailModal, aber dort kein Modal-Stacking
weil Settings kein Modal ist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:05:42 +02:00
duffyduck 64a17c8c19 release: bump version to 0.1.3.2 2026-05-14 13:59:09 +02:00
duffyduck ebeacba8b5 fix(chat): Spezial-Bubbles raus aus Chat → Inbox + Emoji-Bug behoben
Zwei Bugs aus Stefans Screenshot:

1. memorySaved/triggerCreated/skillCreated bleiben permanent unten im
   Chat-Verlauf statt mit den anderen Bubbles zu scrollen — sieht aus
   wie Werbe-Bumper. Fix: chatVisibleMessages-Filter raus aus
   FlatList-Source, diese Bubbles werden im Chat ueberhaupt nicht mehr
   gerendert.

   Stefans urspruengliche Idee war ja "trigger und gedächtnis bubble
   in ein extra modal fenster" — genau das ist die Inbox jetzt.

2. Inbox-Emoji 🗂️ wurde als Literal "🗂️"-Text
   gerendert. Letztes Edit hat es ohne JSX-String-Literal-Schutz
   eingefuegt. Fix: {'🗂️'} statt direktes Emoji-Token.
   Modal-Header analog.

Inbox-Modal erweitert:
- Neue Section "AUS DIESEM CHAT" oben: kompakte Liste der Spezial-
  Bubbles aus messages (chronologisch neueste oben). Memory-Eintraege
  oeffnen MemoryDetailModal (mit Tap auf den Pfeil). Trigger/Skills
  zeigen nur Title+Meta — keine Edit-UI, dafuer gibt's die jeweiligen
  Tabs im Diagnostic.
- Darunter wie bisher der volle MemoryBrowser mit allen DB-Memories.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:56:39 +02:00
27 changed files with 2257 additions and 186 deletions
+4
View File
@@ -25,6 +25,10 @@ aria-data/brain-import/*
!aria-data/brain-import/.gitkeep
!aria-data/brain-import/README.md
# .aria-debug/ — App-Crash-Logs die tools/fetch-app-logs.sh hier ablegt.
# Komplett lokal, enthaelt potentiell private Stacktraces / Daten.
.aria-debug/
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
aria-data/brain/data/
+26 -7
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
- **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
- **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
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
@@ -219,11 +219,15 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
Danach wird der Proxy gepatcht:
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
3. **CLI-Timeout** (sed): `DEFAULT_TIMEOUT 300000 → 1200000` (5 → 20 Min) im subprocess-manager. Multi-Tool-Workflows mit echtem Bash + curl + DB-Inserts brauchen oft 815 Min; 5 Min war chronisch zu kurz
4. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
- `routes.js` hookt die `assistant`-Events des Subprozesses und feuert pro `tool_use`-Block (Bash, Read, Edit, Grep, …) einen HTTP-POST an die Bridge (`/internal/agent-activity`). Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → der Gedanken-Stream zeigt live mit was ARIA gerade tut. Fire-and-forget, fail-open — Brain-Call bricht nicht ab wenn die Bridge mal nicht da ist.
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren. Der Tool-Hook im `routes.js` macht zusaetzlich das interne Claude-Code-Werkzeug-Geschehen fuer den User sichtbar.
**Brain ↔ Bridge ist async**: `_handle_rvs_message` ruft `send_to_core` als `asyncio.create_task` statt `await` — sonst blockierte der WS-recv-Loop bis zu 20 Min und der RVS-Server (mobil.hacker-net.de) droppte die Bridge nach ~4 Min Idle-Timeout. Brain laeuft jetzt im Hintergrund-Task, RVS-Verbindung bleibt waehrend ARIA arbeitet aktiv.
**Wichtige Umgebungsvariablen im Proxy:**
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
@@ -316,10 +320,17 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **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, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), 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)
- **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.
- **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
@@ -355,8 +366,14 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **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 — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
- **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
- **💭 Gedanken-Stream**: chronologisches Log was ARIA intern macht — gefuettert aus `agent_activity`-Events (denkt / 🔧 Tool-Name / schreibt / ✓ fertig). Live-Update waehrend Brain arbeitet: pro Tool-Call (Bash, Read, Edit, Grep, …) erscheint sofort ein Eintrag, durchgereicht vom claude-max-api-proxy via `proxy-patches/routes.js`-Hook. Lange Pausen zwischen Denk-Phasen werden als Trennlinie mit Minuten-Hint sichtbar. App: Icon in der Statusleiste oeffnet ein Bottom-Sheet, persistiert in AsyncStorage (capped 500). Diagnostic: identische Funktion als zentrales Modal im Chat-Test-Header
- **🗂️ 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
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
@@ -867,10 +884,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 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). 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] **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-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] 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
+4 -1
View File
@@ -13,7 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
// --- Navigation ---
@@ -49,6 +49,9 @@ const App: React.FC = () => {
// initLogger ist async aber blockt nichts — solange er noch laueft,
// loggen wir normal (Default an), danach respektiert console.log das Setting.
initLogger().catch(() => {});
// Crash-Reporter installieren — ungefangene JS-Errors landen via RVS
// bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API)
installGlobalCrashReporter();
const initConnection = async () => {
const config = await rvs.loadConfig();
if (config) {
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10301
versionName "0.1.3.1"
versionCode 10501
versionName "0.1.5.1"
// 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.3.1",
"version": "0.1.5.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+89
View File
@@ -0,0 +1,89 @@
/**
* ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box
* statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt,
* der das ueber RVS an die Bridge weiterleitet.
*
* Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze
* App killt.
*/
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { reportAppError } from '../services/logger';
interface Props {
children: React.ReactNode;
/** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */
scope?: string;
/** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */
onReset?: () => void;
}
interface State {
err: Error | null;
info: string;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { err: null, info: '' };
}
static getDerivedStateFromError(err: Error): Partial<State> {
return { err };
}
componentDidCatch(err: Error, info: any) {
const stack = info?.componentStack || '';
this.setState({ info: stack });
reportAppError({
scope: this.props.scope || 'ErrorBoundary',
message: err?.message || String(err),
stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack,
});
}
render() {
if (this.state.err) {
return (
<View style={s.box}>
<Text style={s.title}> Etwas ist schiefgegangen</Text>
<Text style={s.scope}>{this.props.scope || 'unbekannte Komponente'}</Text>
<ScrollView style={s.scroll}>
<Text style={s.msg}>{this.state.err.message || String(this.state.err)}</Text>
{this.state.info ? <Text style={s.stack}>{this.state.info}</Text> : null}
</ScrollView>
{this.props.onReset ? (
<TouchableOpacity style={s.btn} onPress={() => { this.setState({err:null,info:''}); this.props.onReset?.(); }}>
<Text style={s.btnText}>Schliessen + zurueck</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={s.btn} onPress={() => this.setState({err:null,info:''})}>
<Text style={s.btnText}>Erneut versuchen</Text>
</TouchableOpacity>
)}
<Text style={s.hint}>
Crash wurde an die Bridge gemeldet sichtbar in der Diagnostic-Web-UI unter /api/app-log
</Text>
</View>
);
}
return this.props.children;
}
}
const s = StyleSheet.create({
box: { flex:1, padding:16, backgroundColor:'#1A0A0A' },
title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 },
scope: { color:'#FF9500', fontSize:12, marginBottom:10 },
scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 },
msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 },
stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' },
btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' },
btnText: { color:'#fff', fontWeight:'600' },
hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' },
});
export default ErrorBoundary;
+68 -33
View File
@@ -37,9 +37,11 @@ interface Props {
title?: string;
/** Style-Erweiterung fuer den Container. */
flatStyle?: boolean;
/** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */
onOpenMemory?: (id: string) => void;
}
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle }) => {
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
const [items, setItems] = useState<Memory[]>([]);
const [filtered, setFiltered] = useState<Memory[]>([]);
const [loading, setLoading] = useState(false);
@@ -82,38 +84,35 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
setFiltered(out);
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
const [newMemoryTitle, setNewMemoryTitle] = useState('');
const onAddNew = () => {
Alert.prompt(
'Neue Memory',
'Titel:',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Anlegen',
onPress: async (title?: string) => {
const t = (title || '').trim();
if (!t) return;
try {
const m = await brainApi.saveMemory({
type: 'fact', title: t,
content: '(noch leer — bitte editieren)',
});
load();
setOpenId(m.id);
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
},
},
],
'plain-text',
);
setNewMemoryTitle('');
setShowNewMemoryDialog(true);
};
const confirmAddNew = async () => {
const t = newMemoryTitle.trim();
if (!t) { setShowNewMemoryDialog(false); return; }
setShowNewMemoryDialog(false);
try {
const m = await brainApi.saveMemory({
type: 'fact', title: t,
content: '(noch leer — bitte editieren)',
});
load();
if (onOpenMemory) onOpenMemory(m.id);
else setOpenId(m.id);
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
};
const renderItem = ({ item }: { item: Memory }) => {
const attCount = (item.attachments || []).length;
return (
<TouchableOpacity style={s.row} onPress={() => setOpenId(item.id)}>
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
<View style={{flex:1}}>
<Text style={s.rowTitle} numberOfLines={1}>
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
@@ -170,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
data={filtered}
keyExtractor={m => m.id}
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={
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
@@ -202,12 +207,42 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
</TouchableOpacity>
</Modal>
<MemoryDetailModal
memoryId={openId}
visible={!!openId}
onClose={() => { setOpenId(null); load(); }}
onDeleted={() => { setOpenId(null); load(); }}
/>
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
(vermeidet Modal-in-Modal-Stacking auf Android). */}
{!onOpenMemory && (
<MemoryDetailModal
memoryId={openId}
visible={!!openId}
onClose={() => { setOpenId(null); load(); }}
onDeleted={() => { setOpenId(null); load(); }}
/>
)}
{/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */}
<Modal visible={showNewMemoryDialog} transparent animationType="fade" onRequestClose={() => setShowNewMemoryDialog(false)}>
<View style={s.menuBack}>
<View style={[s.menuBox, {padding:16, minWidth:280}]}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:14, marginBottom:10}}>Neue Memory anlegen</Text>
<Text style={{color:'#8888AA', fontSize:11, marginBottom:6}}>Titel:</Text>
<TextInput
value={newMemoryTitle}
onChangeText={setNewMemoryTitle}
autoFocus
placeholder="z.B. Stefans Auto"
placeholderTextColor="#555570"
style={{backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:4, fontSize:13, marginBottom:12}}
/>
<View style={{flexDirection:'row', gap:8, justifyContent:'flex-end'}}>
<TouchableOpacity onPress={() => setShowNewMemoryDialog(false)} style={{padding:8}}>
<Text style={{color:'#8888AA'}}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={confirmAddNew} style={{backgroundColor:'#0096FF', paddingHorizontal:14, paddingVertical:8, borderRadius:4}}>
<Text style={{color:'#fff', fontWeight:'600'}}>Anlegen</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -868,7 +868,7 @@ const SettingsScreen: React.FC = () => {
})()}
</View>
</Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
{currentSection === null && (
<>
+29 -15
View File
@@ -54,6 +54,18 @@ function _newRequestId(): string {
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 {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: AnyJson;
@@ -119,29 +131,31 @@ export const brainApi = {
/** Liste aller Memories, optional nach Type gefiltert. */
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams();
if (opts.type) qs.set('type', opts.type);
qs.set('limit', String(opts.limit || 500));
return _send(`/memory/list?${qs.toString()}`);
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
return _send(`/memory/list${qs}`);
},
/** Volltext-Substring-Suche. */
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q });
if (opts.type) qs.set('type', opts.type);
qs.set('include_pinned', String(opts.includePinned !== false));
qs.set('k', String(opts.k || 50));
return _send(`/memory/search-text?${qs.toString()}`);
const qs = _qs({
q,
type: opts.type,
include_pinned: opts.includePinned !== false,
k: opts.k || 50,
});
return _send(`/memory/search-text${qs}`);
},
/** Semantische Suche (Embedder). */
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q });
if (opts.type) qs.set('type', opts.type);
qs.set('include_pinned', String(opts.includePinned !== false));
qs.set('k', String(opts.k || 10));
qs.set('score_threshold', String(opts.threshold ?? 0.30));
return _send(`/memory/search?${qs.toString()}`);
const qs = _qs({
q,
type: opts.type,
include_pinned: opts.includePinned !== false,
k: opts.k || 10,
score_threshold: opts.threshold ?? 0.30,
});
return _send(`/memory/search${qs}`);
},
/** Memory anlegen. */
+76
View File
@@ -7,6 +7,8 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import rvs from './rvs';
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
@@ -39,3 +41,77 @@ export function setVerboseLogging(verbose: boolean): void {
applyState();
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
//
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
// ungefangener JS-Error (ErrorUtils-Handler) — schicken wir den Crash
// als RVS-Message vom Typ "app_log" an die Bridge. Die schreibt in
// /shared/logs/app.log, sodass wir/Diagnostic die Crashes mitlesen
// koennen ohne ADB.
interface AppErrorEvent {
scope: string;
message: string;
stack?: string;
level?: 'error' | 'warn' | 'info';
}
let _reportingInstalled = false;
/** Schickt einen App-Fehler via RVS an die Bridge. */
export function reportAppError(ev: AppErrorEvent): void {
try {
rvs.send('app_log' as any, {
ts: Date.now(),
platform: Platform.OS,
level: ev.level || 'error',
scope: ev.scope,
message: ev.message,
stack: (ev.stack || '').slice(0, 8000),
});
} catch {
// RVS noch nicht connected — Fehler geht im console weiter.
}
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
// den Crash sieht.
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
}
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
export function installGlobalCrashReporter(): void {
if (_reportingInstalled) return;
_reportingInstalled = true;
try {
const g: any = global as any;
const prev = g.ErrorUtils?.getGlobalHandler?.();
g.ErrorUtils?.setGlobalHandler?.((err: any, isFatal: boolean) => {
reportAppError({
scope: isFatal ? 'global-fatal' : 'global-nonfatal',
message: (err && err.message) || String(err),
stack: err && err.stack,
});
// Original-Handler weiterhin aufrufen damit React-Native das System-
// Crash-Overlay zeigt (im Dev-Build) bzw. in Production sauber stirbt.
if (typeof prev === 'function') {
try { prev(err, isFatal); } catch {}
}
});
// unhandled Promise-Rejections — manche RN-Versionen haben das nicht
// automatisch im ErrorUtils.
g.HermesInternal?.enablePromiseRejectionTracker?.({
allRejections: true,
onUnhandled: (id: number, err: any) => {
reportAppError({
scope: 'promise-unhandled',
level: 'warn',
message: (err && err.message) || String(err),
stack: err && err.stack,
});
},
});
} catch {
// ErrorUtils nicht da → nix machen
}
}
+11 -2
View File
@@ -134,10 +134,19 @@ META_TOOLS = [
"function": {
"name": "trigger_watcher",
"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). "
"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": {
"type": "object",
+68 -19
View File
@@ -27,7 +27,12 @@ import watcher as watcher_mod
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")
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, 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:
all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e:
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
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:
if trigger.get("type") != "watcher":
continue
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,
# 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)
# 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:
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
global _AGENT_FACTORY
_AGENT_FACTORY = agent_factory
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
while True:
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)}
@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")
def triggers_conditions():
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
+11 -10
View File
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
"Stefan fragen ob es ins Brain-Dockerfile soll.")
lines.append("")
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
"Container-Restart wiederholt.")
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
"macht ARIA stumm und nervt Stefan jedes Mal.")
lines.append("")
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
lines.append("")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
lines.append("")
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
"'bau daraus einen Skill'.")
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
return "\n".join(lines)
+1 -1
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
def _read_model_from_runtime() -> str:
+81 -7
View File
@@ -25,7 +25,7 @@ import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
_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]:
"""Letzte bekannte Position aus /shared/state/location.json.
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
return int(time.time() - ts)
def collect_variables() -> dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
def _near_key(lat: float, lon: float, radius_m: float) -> str:
"""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()
now = datetime.now()
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.
# 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."""
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
"""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_lon = vars_.get("current_lon")
if cur_lat is None or cur_lon is None:
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:
R = 6371000.0
phi1 = math.radians(float(cur_lat))
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
except Exception:
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_["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_
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
{
"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.",
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
"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.",
},
]
+169 -17
View File
@@ -25,6 +25,7 @@ import time
import sys
import tempfile
import uuid
from collections import OrderedDict
from pathlib import Path
from typing import Optional
@@ -475,6 +476,13 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode()
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)
self.tts_enabled = True
self.xtts_voice = ""
@@ -938,7 +946,12 @@ class ARIABridge:
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."""
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):
return
try:
@@ -950,9 +963,31 @@ class ARIABridge:
"lat": float(lat),
"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:
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:
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
Watcher: last_user_message_ago_sec basiert darauf."""
@@ -962,8 +997,13 @@ class ARIABridge:
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
Wird von Diagnostic + App als History-Quelle gelesen.
entry braucht mindestens {role, text}; ts wird ergaenzt.
Returns den ts (auch fuer Bubble-Loeschen-Tracking)."""
ts = int(asyncio.get_event_loop().time() * 1000)
Returns den ts (auch fuer Bubble-Loeschen-Tracking).
WICHTIG: ts ist UNIX-ms (time.time()*1000), NICHT loop-time.
Loop-time ist Container-monotonic — bei jedem Restart wieder 0.
Das brach die App-History-Sortierung weil App-side Date.now()
(echtes UNIX-ms) mit Bridge-Container-Uptime gemischt wurde."""
ts = int(time.time() * 1000)
try:
line = {"ts": ts}
line.update(entry)
@@ -1281,10 +1321,12 @@ class ARIABridge:
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
# fertig ist (siehe chat-handler oben).
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None:
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
@@ -1298,8 +1340,13 @@ class ARIABridge:
logger.info("[brain] chat ← %s '%s'", source, text[:80])
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
# / Diagnostic-Reload als History-Quelle gelesen.
self._append_chat_backup({"role": "user", "text": text, "source": source})
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
# damit die App beim chat_history_response ihre lokale Bubble
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
entry: dict = {"role": "user", "text": text, "source": source}
if client_msg_id:
entry["clientMsgId"] = client_msg_id
self._append_chat_backup(entry)
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
@@ -1311,8 +1358,10 @@ class ARIABridge:
url, data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
# Cold-Start kann lange dauern, 5min Timeout
with urllib.request.urlopen(req, timeout=300) as resp:
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
with urllib.request.urlopen(req, timeout=1200) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as exc:
return None, str(exc)
@@ -1503,6 +1552,36 @@ class ARIABridge:
except Exception:
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:
"""Verarbeitet Nachrichten von der App (via RVS).
@@ -1527,6 +1606,13 @@ class ARIABridge:
sender = payload.get("sender", "")
if sender in ("aria", "stt"):
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", "")
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
@@ -1562,7 +1648,16 @@ class ARIABridge:
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
# KEIN await: send_to_core kann 20 Min dauern. Wenn wir
# hier awaiten, blockt der `async for raw_message in ws`-
# Loop solange → RVS-Server droppt uns nach ~4 Min idle.
# Als Task: Brain laeuft im Hintergrund, RVS-recv bleibt
# bedienbar, Pings werden beantwortet, Verbindung lebt.
asyncio.create_task(self.send_to_core(
core_text,
source="app" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id,
))
return
if msg_type == "cancel_request":
@@ -1738,7 +1833,8 @@ class ARIABridge:
if not file_b64:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
# create_task statt await — RVS-recv darf nicht blocken
asyncio.create_task(self.send_to_core(text, source="app-file"))
return
if file_type.startswith("image/"):
@@ -1824,6 +1920,29 @@ class ARIABridge:
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
return
elif msg_type == "app_log":
# App schickt Crash/Error/Info-Log via RVS — wir schreiben das
# in /shared/logs/app.log (JSONL) damit Diagnostic + Claude
# mitlesen koennen, auch ohne ADB-Zugriff aufs Handy.
try:
log_dir = Path("/shared/logs")
log_dir.mkdir(parents=True, exist_ok=True)
line = {
"ts": payload.get("ts") or int(time.time() * 1000),
"platform": payload.get("platform", "?"),
"level": payload.get("level", "info"),
"scope": payload.get("scope", ""),
"message": payload.get("message", ""),
"stack": payload.get("stack", ""),
}
with (log_dir / "app.log").open("a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
logger.info("[app-log] %s %s: %s",
line["level"], line["scope"], line["message"][:120])
except Exception as exc:
logger.warning("[app-log] schreiben fehlgeschlagen: %s", exc)
return
elif msg_type == "brain_request":
# Generischer RVS-Proxy fuer die Brain-HTTP-API.
# payload: {requestId, method, path, body?, bodyBase64?, contentType?}
@@ -2103,6 +2222,12 @@ class ARIABridge:
elif msg_type == "audio":
# 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", "")
mime_type = payload.get("mimeType", "audio/mp4")
duration_ms = payload.get("durationMs", 0)
@@ -2133,7 +2258,8 @@ class ARIABridge:
" [GPS]" if location else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio(
audio_b64, mime_type, interrupted, audio_request_id, location))
audio_b64, mime_type, interrupted, audio_request_id, location,
client_msg_id=client_msg_id))
elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request
@@ -2192,7 +2318,8 @@ class ARIABridge:
async def _process_app_audio(self, audio_b64: str, mime_type: str,
interrupted: bool = False,
audio_request_id: str = "",
location: Optional[dict] = None) -> None:
location: Optional[dict] = None,
client_msg_id: Optional[str] = None) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
@@ -2248,7 +2375,9 @@ class ARIABridge:
# 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 ""))
await self.send_to_core(core_text,
source="app-voice" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
@@ -2395,17 +2524,22 @@ class ARIABridge:
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
async def _emit_activity(self, activity: str, tool: str = "") -> None:
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
(nur 'idle' kommt immer durch)."""
(nur 'idle' kommt immer durch).
force=True: kein State-Dedup — wird vom Proxy-Tool-Hook genutzt
damit auch wiederholte gleiche Tool-Aufrufe (z.B. 3x Bash
hintereinander) im Gedanken-Stream als eigene Eintraege sichtbar
bleiben."""
if activity != "idle" and self._last_chat_final_at > 0:
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
if since_final < 3.0:
return
state = (activity, tool)
if state == self._last_activity_state:
if not force and state == self._last_activity_state:
return
self._last_activity_state = state
await self._send_to_rvs({
@@ -2553,6 +2687,24 @@ class ARIABridge:
self._handle_trigger_fired(reply, trigger_name, ttype, events)
)
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-activity":
# Vom Proxy gefeuert bei jedem Claude-Code-tool_use-Event
# (Bash, Read, Edit, Grep, ...). Wir spiegeln das als
# RVS agent_activity an App+Diagnostic damit der Gedanken-
# Stream live mitlaufen kann.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
tool = (data.get("tool") or "").strip()
if not tool:
await _send_response(writer, 400, {"error": "tool erforderlich"})
return
# Force-emit (kein Dedup): User soll JEDEN Tool-Call sehen
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/delete-chat-message":
try:
data = json.loads(body.decode("utf-8", "ignore"))
+129
View File
@@ -301,6 +301,7 @@
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
GPS-Position einblenden
</label>
<button class="btn secondary" onclick="openThoughtStream()" id="btn-thoughts" title="Gedanken-Stream — was ARIA intern tut" style="padding:4px 10px;font-size:11px;">&#x1F4AD; Gedanken <span id="thoughts-count" style="color:#8888AA;"></span></button>
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div>
</div>
@@ -342,6 +343,22 @@
</div>
</div>
<!-- Gedanken-Stream Modal — chronologisches Log was ARIA intern tut.
Zentrales Modal (max 720px breit), Liste mit Auto-Scroll ans Ende
wenn neue Eintraege reinkommen. -->
<div id="thought-stream-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;padding:24px;" onclick="if(event.target===this) closeThoughtStream();">
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:12px;width:100%;max-width:720px;height:70vh;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;padding:14px;border-bottom:1px solid #1E1E2E;">
<h2 style="margin:0;color:#FFD60A;flex:1;font-size:16px;">&#x1F4AD; Gedanken-Stream <span id="thoughts-count-modal" style="color:#8888AA;font-weight:normal;"></span></h2>
<button class="btn secondary" onclick="clearThoughtStream()" id="btn-clear-thoughts" title="Stream leeren" style="padding:4px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;margin-right:6px;">&#x1F5D1; Leeren</button>
<button class="btn secondary" onclick="closeThoughtStream()" style="padding:4px 12px;">Schliessen</button>
</div>
<div id="thought-stream-list" style="flex:1;overflow-y:auto;padding:8px 0;font-size:13px;font-family:monospace;">
<!-- gefuellt durch renderThoughtStream() -->
</div>
</div>
</div>
<!-- Sessions + alter Brain-Viewer entfernt — Memories laufen jetzt
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
@@ -2166,6 +2183,9 @@
}
function updateThinkingIndicator(msg) {
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
pushThought(msg.activity || '', msg.tool || '');
const indicators = [
document.getElementById('thinking-indicator'),
document.getElementById('thinking-indicator-fs'),
@@ -2202,6 +2222,114 @@
}, 120000);
}
// ── Gedanken-Stream ─────────────────────────────
// Chronologisches Log von agent_activity-Events. Wird in localStorage
// persistiert (ueberlebt Page-Reload), capped auf MAX_THOUGHTS.
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
const MAX_THOUGHTS = 500;
let thoughtStream = [];
let lastThoughtKey = '';
let _thoughtSaveTimer = null;
function loadThoughtStream() {
try {
const raw = localStorage.getItem(THOUGHT_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) thoughtStream = parsed.slice(-MAX_THOUGHTS);
} catch {}
updateThoughtsBadge();
}
function persistThoughtStream() {
if (_thoughtSaveTimer) clearTimeout(_thoughtSaveTimer);
_thoughtSaveTimer = setTimeout(() => {
try {
if (thoughtStream.length === 0) localStorage.removeItem(THOUGHT_STORAGE_KEY);
else localStorage.setItem(THOUGHT_STORAGE_KEY, JSON.stringify(thoughtStream.slice(-MAX_THOUGHTS)));
} catch {}
}, 500);
}
function pushThought(activity, tool) {
// Dedup gegen direkt aufeinanderfolgende identische Events. Tool-
// Events NIE dedupen — drei Bash-Calls in Folge sollen drei Eintraege
// ergeben, nicht einen.
const key = `${activity}|${tool || ''}`;
if (activity !== 'tool' && key === lastThoughtKey) return;
lastThoughtKey = key;
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
updateThoughtsBadge();
// Wenn das Modal offen ist: live nachrendern + ans Ende scrollen
const modal = document.getElementById('thought-stream-modal');
if (modal && modal.style.display !== 'none') renderThoughtStream(true);
persistThoughtStream();
}
function updateThoughtsBadge() {
const a = document.getElementById('thoughts-count');
if (a) a.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
const b = document.getElementById('thoughts-count-modal');
if (b) b.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
}
function openThoughtStream() {
const modal = document.getElementById('thought-stream-modal');
if (!modal) return;
modal.style.display = 'flex';
renderThoughtStream(true);
}
function closeThoughtStream() {
const modal = document.getElementById('thought-stream-modal');
if (modal) modal.style.display = 'none';
}
function clearThoughtStream() {
if (thoughtStream.length === 0) return;
if (!confirm(`Gedanken-Stream leeren? ${thoughtStream.length} Eintraege werden geloescht.`)) return;
thoughtStream = [];
lastThoughtKey = '';
updateThoughtsBadge();
renderThoughtStream(false);
persistThoughtStream();
}
function _escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderThoughtStream(autoscroll) {
const list = document.getElementById('thought-stream-list');
if (!list) return;
if (thoughtStream.length === 0) {
list.innerHTML = '<div style="padding:24px;text-align:center;color:#555570;font-style:italic;">Noch keine Gedanken aufgezeichnet.<br>Sobald ARIA was tut, taucht\'s hier auf.</div>';
return;
}
const rows = [];
let prevTs = 0;
for (const t of thoughtStream) {
const gapMin = prevTs ? Math.floor((t.ts - prevTs) / 60000) : 0;
if (gapMin >= 1) {
const label = gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`;
rows.push(`<div style="display:flex;align-items:center;padding:6px 16px;gap:8px;"><div style="flex:1;height:1px;background:#1E1E2E;"></div><span style="color:#555570;font-size:10px;">${label}</span><div style="flex:1;height:1px;background:#1E1E2E;"></div></div>`);
}
prevTs = t.ts;
const d = new Date(t.ts);
const time = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
let icon, label, color;
if (t.activity === 'idle') { icon = '✓'; label = 'fertig'; color = '#34C759'; }
else if (t.activity === 'tool') { icon = '🔧'; label = t.tool || 'tool'; color = '#E0E0F0'; }
else if (t.activity === 'assistant'){ icon = '✍️'; label = 'schreibt'; color = '#E0E0F0'; }
else if (t.activity === 'thinking'){ icon = '💭'; label = 'denkt'; color = '#E0E0F0'; }
else { icon = '•'; label = t.activity; color = '#E0E0F0'; }
rows.push(`<div style="display:flex;padding:4px 16px;align-items:baseline;"><span style="color:#555570;width:78px;font-size:11px;">${time}</span><span style="width:24px;">${icon}</span><span style="color:${color};flex:1;">${_escapeHtml(label)}</span></div>`);
}
list.innerHTML = rows.join('');
if (autoscroll) list.scrollTop = list.scrollHeight;
}
// ── XTTS Panel ─────────────────────────────
function renderVoiceList(voices) {
const box = document.getElementById('xtts-voice-list');
@@ -4696,6 +4824,7 @@
});
}
loadThoughtStream();
connectWS();
</script>
</body>
+36
View File
@@ -1338,6 +1338,42 @@ const server = http.createServer((req, res) => {
else broadcast({ type: "agent_activity", activity: "idle" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else if (req.url.startsWith("/api/app-log") && req.method === "GET") {
// App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
// Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
// letzten 200 Eintraege (oder ?limit=N).
const url = new URL(req.url, "http://x");
const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
try {
const file = "/shared/logs/app.log";
if (!fs.existsSync(file)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 0, entries: [] }));
return;
}
const raw = fs.readFileSync(file, "utf-8");
const lines = raw.split("\n").filter(l => l.trim());
const tail = lines.slice(-limit);
const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: entries.length, entries }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
return;
} else if (req.url === "/api/app-log/clear" && req.method === "POST") {
// App-Log leeren — nach erfolgreichem Debug.
try {
const file = "/shared/logs/app.log";
if (fs.existsSync(file)) fs.unlinkSync(file);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/files-list" && req.method === "GET") {
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
+2
View File
@@ -12,8 +12,10 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
claude-max-api"
volumes:
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
+41 -2
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] **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)
- [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
@@ -324,14 +341,36 @@ Skills mit Tool-Use.
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
- [x] **chat_backup ts war Container-Uptime statt UNIX-ms**: `_append_chat_backup` nutzte `asyncio.get_event_loop().time()` (Monotonic, bei jedem Restart wieder 0) statt `time.time()`. Folge: Server-Bubbles mit ts wie 394M (6 min Uptime) wurden in der App-History neben App-side Bubbles mit Date.now() (1.778e12) sortiert — Hello-Kitty-Konversation von gestern landete chronologisch nach heutigen Karten-Routen, neue Nachrichten verschwanden unter dem 500er-Cap. Plus: Doppelpost-Schutz griff nicht weil das 5-Min-ts-Fenster bei 1.7 Bio ms Diff nie zutraf. Fix: Bridge schreibt jetzt UNIX-ms, Migration-Script `tools/migrate_chat_backup_ts.py` repariert vorhandene jsonl (284/299 ts umgeschrieben auf der VM, Datei-Reihenfolge bleibt). App-Merge dedupt zusaetzlich per blossem Text-Match (ohne ts-Diff) — schuetzt auch gegen vorhandene lokale Duplikate
- [x] **User-Bubble ⏳→failed bei langsamen ARIA-Antworten**: ACK-Timer (30 s × 3 Retries) lief durch obwohl Brain laengst arbeitete — wenn `chat_ack` aus irgendwelchen Gruenden nicht durchkam (RVS-Frame verloren etc.), wurde die Bubble nach 90 s auf failed gesetzt obwohl die Antwort gleich danach kam. Fix: jedes `agent_activity != idle`-Event ist impliziter ACK — Brain wuerde nicht arbeiten wenn es die Nachricht nicht haette. Beim ersten non-idle Event werden alle laufenden ACK-Timer gecanceled und sending-Bubbles auf 'sent' gesetzt. ACK_TIMEOUT_MS zusaetzlich von 30 s auf 60 s als Backup
- [x] **Gedanken-Stream Modal scrollte nicht**: innerer `TouchableOpacity` (eigentlich nur fuer close-on-tap-outside-Schutz) hat alle Touch-Events konsumiert. Fix: durch `View` mit `onStartShouldSetResponder={true}` + `onResponderTerminationRequest={false}` ersetzt — blockt Tap-Propagation ohne Scrolls der Children zu verschlucken
### Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-Aggressivitaet
- [x] **Skill-Erstellung aggressiver als gewollt**: Prompt sagte „Harte Regel — IMMER Skill anlegen wenn pip-Library noetig". ARIA hat das wortwoertlich genommen und bei einer simplen pdf-extract-Frage sofort `skill_create` aufgerufen → Brain 12 Min blockiert (venv 2 min + pip install 10 min Timeout in `skills.py`). App zeigt „ARIA denkt", Bridge emitted nach 5 Min Timeout idle, User ohne Antwort. Fix in `prompts.py`: „Goldene Regel: NIE ungefragt Skills anlegen" + nur bei expliziter Anfrage („mach daraus einen Skill") und auch dann nur wenn die 4 Kriterien (wiederkehrend / nicht-trivial / parametrisierbar / wiederverwendbar) zutreffen. Greift auf der VM nach `docker compose restart aria-brain` ohne Re-Build
- [x] **Brain-Timeouts 5 Min → 20 Min**: drei verkettete 5-Min-Timeouts (Bridge `urlopen`, Brain `proxy_client`, Proxy `DEFAULT_TIMEOUT` im claude-max-api-proxy npm-Modul) feuerten exakt gleichzeitig. Live in den Logs nachvollzogen: ein Proxy-Call brauchte 4m51s und wurde von der Bridge auf den Sekundenbruchteil genau gekappt. Aufgabenstellungen wie Karten-Rekonstruktion mit 10+ curl-Calls oder PDF-Verarbeitung brauchen aber locker 815 Min. Fix: alle drei Timeouts auf 1200 s, plus dritter sed-Patch im docker-compose proxy-Service (`DEFAULT_TIMEOUT = 300000 → 1200000`). App-Stuck-Watchdog auf 1260 s (21 Min, knapp drueber)
- [x] **RVS-Block waehrend Brain-Call** (mobil.hacker-net.de:444 droppt nach 4 Min idle): `async for raw_message in ws: await _handle_rvs_message(...)` — das await blockierte den recv-Loop solange `send_to_core` lief. Die websockets-Lib beantwortete Pings im Hintergrund, aber der RVS-Server zaehlt nur echte App-Frames und droppt sonst die Verbindung. Symptom: App+Diagnostic zeigten „abgebrochen" obwohl Brain noch arbeitete. Fix: `send_to_core` als `asyncio.create_task` statt `await` — RVS-recv-Loop bleibt frei, neue Messages werden weiter verarbeitet, Verbindung bleibt lebendig
### Gedanken-Stream + Live-Tool-Events
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
## Offen
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
+309
View File
@@ -0,0 +1,309 @@
/**
* ARIA-patched API Route Handlers
*
* Erweiterung der npm-Version von claude-max-api-proxy:
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
* Edit, Grep, ) wird ein HTTP-POST an die Bridge gefeuert
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml proxy-Block).
*/
import { v4 as uuidv4 } from "uuid";
import http from "http";
import { ClaudeSubprocess } from "../subprocess/manager.js";
import { openaiToCli } from "../adapter/openai-to-cli.js";
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity";
/**
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget keine Awaits,
* keine Fehler nach oben. Logged Fehler still.
*/
function _emitToolEvent(toolName) {
if (!toolName) return;
try {
const u = new URL(TOOL_HOOK_URL);
const body = JSON.stringify({ tool: String(toolName) });
const req = http.request({
method: "POST",
hostname: u.hostname,
port: u.port || 80,
path: u.pathname,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
timeout: 2000,
}, (res) => { res.resume(); });
req.on("error", () => {});
req.on("timeout", () => req.destroy());
req.write(body);
req.end();
} catch (_) { /* niemals weiterwerfen */ }
}
/**
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
* kann mehrere content-Bloecke haben tool_use-Bloecke pushen wir live.
*/
function _attachToolHook(subprocess) {
subprocess.on("assistant", (message) => {
try {
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_use" && b.name) {
_emitToolEvent(b.name);
}
}
} catch (_) { /* fail-open */ }
});
}
/**
* Handle POST /v1/chat/completions
*
* Main endpoint for chat requests, supports both streaming and non-streaming
*/
export async function handleChatCompletions(req, res) {
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
const body = req.body;
const stream = body.stream === true;
try {
// Validate request
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
res.status(400).json({
error: {
message: "messages is required and must be a non-empty array",
type: "invalid_request_error",
code: "invalid_messages",
},
});
return;
}
// Convert to CLI input format
const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess();
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
// Greift fuer beide Branches (stream + non-stream).
_attachToolHook(subprocess);
if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
}
else {
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
}
}
catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[handleChatCompletions] Error:", message);
if (!res.headersSent) {
res.status(500).json({
error: {
message,
type: "server_error",
code: null,
},
});
}
}
}
/**
* Handle streaming response (SSE)
*
* IMPORTANT: The Express req.on("close") event fires when the request body
* is fully received, NOT when the client disconnects. For SSE connections,
* we use res.on("close") to detect actual client disconnection.
*/
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Request-Id", requestId);
// CRITICAL: Flush headers immediately to establish SSE connection
// Without this, headers are buffered and client times out waiting
res.flushHeaders();
// Send initial comment to confirm connection is alive
res.write(":ok\n\n");
return new Promise((resolve, reject) => {
let isFirst = true;
let lastModel = "claude-sonnet-4";
let isComplete = false;
// Handle actual client disconnect (response stream closed)
res.on("close", () => {
if (!isComplete) {
// Client disconnected before response completed - kill subprocess
subprocess.kill();
}
resolve();
});
// Handle streaming content deltas
subprocess.on("content_delta", (event) => {
const text = event.event.delta?.text || "";
if (text && !res.writableEnded) {
const chunk = {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: lastModel,
choices: [{
index: 0,
delta: {
role: isFirst ? "assistant" : undefined,
content: text,
},
finish_reason: null,
}],
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
isFirst = false;
}
});
// Handle final assistant message (for model name)
subprocess.on("assistant", (message) => {
lastModel = message.message.model;
});
subprocess.on("result", (_result) => {
isComplete = true;
if (!res.writableEnded) {
// Send final done chunk with finish_reason
const doneChunk = createDoneChunk(requestId, lastModel);
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
subprocess.on("error", (error) => {
console.error("[Streaming] Error:", error.message);
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({
error: { message: error.message, type: "server_error", code: null },
})}\n\n`);
res.end();
}
resolve();
});
subprocess.on("close", (code) => {
// Subprocess exited - ensure response is closed
if (!res.writableEnded) {
if (code !== 0 && !isComplete) {
// Abnormal exit without result - send error
res.write(`data: ${JSON.stringify({
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
})}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
// Start the subprocess
subprocess.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
}).catch((err) => {
console.error("[Streaming] Subprocess start error:", err);
reject(err);
});
});
}
/**
* Handle non-streaming response
*/
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
return new Promise((resolve) => {
let finalResult = null;
subprocess.on("result", (result) => {
finalResult = result;
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
resolve();
});
subprocess.on("close", (code) => {
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
}
else if (!res.headersSent) {
res.status(500).json({
error: {
message: `Claude CLI exited with code ${code} without response`,
type: "server_error",
code: null,
},
});
}
resolve();
});
// Start the subprocess
subprocess
.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
})
.catch((error) => {
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
resolve();
});
});
}
/**
* Handle GET /v1/models
*
* Returns available models
*/
export function handleModels(_req, res) {
res.json({
object: "list",
data: [
{
id: "claude-opus-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-sonnet-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-haiku-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
],
});
}
/**
* Handle GET /health
*
* Health check endpoint
*/
export function handleHealth(_req, res) {
res.json({
status: "ok",
provider: "claude-code-cli",
timestamp: new Date().toISOString(),
});
}
//# sourceMappingURL=routes.js.map
+1
View File
@@ -31,6 +31,7 @@ const ALLOWED_TYPES = new Set([
"chat_history_request", "chat_history_response", "chat_cleared",
"delete_message_request", "chat_message_deleted",
"brain_request", "brain_response",
"app_log",
"file_delete_batch_request", "file_delete_batch_response",
"file_zip_request", "file_zip_response",
"xtts_delete_voice",
+34
View File
@@ -0,0 +1,34 @@
# tools/
Hilfsskripte für die Dev-Maschine. Brauchen `.claude/aria-vm.env` (aus
`.example` kopieren + lokale VM-IP eintragen).
## fetch-app-logs.sh
Holt App-Crash-Logs von der VM und speichert sie unter `.aria-debug/`
(gitignored). Die App schickt JS-Errors und ungefangene Promise-
Rejections via RVS an die Bridge — Bridge sammelt in
`/shared/logs/app.log`, Diagnostic-Server gibt sie via
`GET /api/app-log` raus.
```bash
tools/fetch-app-logs.sh # 200 neueste Eintraege
tools/fetch-app-logs.sh --limit 50 # weniger
tools/fetch-app-logs.sh --watch # alle 5s pollen, neue rausgeben
tools/fetch-app-logs.sh --clear # nach Abholen Log auf VM leeren
```
Ausgabe enthaelt pro Eintrag: Uhrzeit, Level (error/warn/info), Scope
(z.B. `ChatScreen.InboxModal` oder `global-fatal`), Message, und die
ersten ~8 Stack-Frames. Die kompletten Daten liegen als JSON in
`.aria-debug/app-log-<timestamp>.json`.
Workflow nach einem Crash:
1. App rebuilden mit Crash-Reporting (passiert automatisch ab dem
`21a315c`-Commit)
2. Crash in der App ausloesen
3. `tools/fetch-app-logs.sh` auf der Dev-Maschine
4. Stacktrace lesen / Claude geben
5. Fix bauen
6. `tools/fetch-app-logs.sh --clear` damit der Log wieder sauber ist
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# fetch-app-logs.sh — App-Crash-Logs von der VM holen
#
# Nutzt .claude/aria-vm.env als Quelle fuer $ARIA_DIAG_URL und ruft
# GET /api/app-log?limit=N. Speichert die Roh-Response unter
# .aria-debug/app-log-<timestamp>.json und gibt eine kompakte
# Zusammenfassung auf stdout aus (letzte Eintraege mit Stack-Trace).
#
# Verwendung:
# tools/fetch-app-logs.sh # Default limit=200
# tools/fetch-app-logs.sh --limit 50 # nur 50 holen
# tools/fetch-app-logs.sh --clear # nach Abholen Log loeschen
# tools/fetch-app-logs.sh --watch # alle 5s pollen, neue Eintraege ausgeben
set -euo pipefail
LIMIT=200
CLEAR=0
WATCH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--limit) LIMIT="$2"; shift 2 ;;
--limit=*) LIMIT="${1#*=}"; shift ;;
--clear) CLEAR=1; shift ;;
--watch) WATCH=1; shift ;;
-h|--help)
sed -n '1,/^set/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="$ROOT/.claude/aria-vm.env"
OUT_DIR="$ROOT/.aria-debug"
if [[ ! -f "$ENV_FILE" ]]; then
echo "FEHLER: $ENV_FILE nicht vorhanden. Aus .example kopieren und IP anpassen." >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if [[ -z "${ARIA_DIAG_URL:-}" ]]; then
echo "FEHLER: ARIA_DIAG_URL nicht gesetzt in $ENV_FILE" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
fetch_once() {
local ts json file
ts="$(date +%Y%m%d_%H%M%S)"
json="$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT")" || {
echo "FEHLER: curl gegen $ARIA_DIAG_URL fehlgeschlagen" >&2
return 1
}
file="$OUT_DIR/app-log-$ts.json"
echo "$json" > "$file"
python3 - "$file" <<'PY'
import json, sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text())
entries = data.get("entries") or []
print(f"=== {len(entries)} Eintrag{'e' if len(entries)!=1 else ''} (gespeichert unter {sys.argv[1]}) ===")
for e in entries[-20:]:
ts = e.get("ts") or 0
from datetime import datetime
when = datetime.fromtimestamp(ts/1000).strftime("%H:%M:%S") if ts else "?"
lvl = e.get("level","?")
scope = e.get("scope","?")
msg = (e.get("message") or "").splitlines()[0][:200]
print(f"\n[{when}] {lvl:5} {scope}: {msg}")
stack = (e.get("stack") or "").strip()
if stack:
for line in stack.splitlines()[:8]:
print(f" {line}")
if len(stack.splitlines()) > 8:
print(f" ... ({len(stack.splitlines())-8} weitere Zeilen — siehe JSON)")
PY
return 0
}
if [[ "$WATCH" == "1" ]]; then
echo "Watching $ARIA_DIAG_URL/api/app-log — Ctrl+C zum Beenden"
SEEN=""
while true; do
cur=$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT") || cur=""
hash=$(echo "$cur" | md5sum | awk '{print $1}')
if [[ "$hash" != "$SEEN" && -n "$cur" ]]; then
SEEN="$hash"
fetch_once
fi
sleep 5
done
else
fetch_once
fi
if [[ "$CLEAR" == "1" ]]; then
echo
echo "→ Log auf der VM leeren..."
curl -s --max-time 5 -X POST "${ARIA_DIAG_URL%/}/api/app-log/clear" | python3 -m json.tool || true
fi
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
`Date.now()` (echtes UNIX-ms) falsche Sortierung in der App-History.
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
werden unveraendert gelassen.
Idempotent: zweimal laufen lassen ist sicher beim zweiten Mal sind alle ts
schon UNIX-ms und werden nicht angefasst.
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
"""
import json
import os
import shutil
import sys
import time
from pathlib import Path
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
def migrate(path: Path) -> None:
if not path.exists():
print(f"Datei nicht da: {path}")
sys.exit(1)
raw = path.read_text(encoding="utf-8").splitlines()
entries = []
for raw_line in raw:
s = raw_line.strip()
if not s:
continue
try:
entries.append(json.loads(s))
except Exception as e:
print(f" ueberspringe kaputte Zeile: {e}")
continue
if not entries:
print("Datei leer")
return
file_mtime_ms = int(os.path.getmtime(path) * 1000)
n = len(entries)
fixed = 0
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
# UNIX-ms bleiben unangetastet.
for i, entry in enumerate(entries):
ts = entry.get("ts", 0)
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
entry["ts"] = new_ts
fixed += 1
if fixed == 0:
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
return
# Backup
bak = path.with_suffix(path.suffix + ".bak")
shutil.copy2(path, bak)
print(f"Backup: {bak}")
# Atomic rewrite
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
for entry in entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
tmp.replace(path)
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
if __name__ == "__main__":
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
migrate(path)