Compare commits

..
775 Commits
Author SHA1 Message Date
duffyduck 03021a6787 release: bump version to 0.2.3.2 2026-08-15 03:24:35 +02:00
duffyduckandClaude Opus 4.8 b6a5d7029f feat(m1): generative Flaeche — Orb + Karten-Renderer (present_view)
Visueller Renderer fuer aria_view: Orb (reanimated-Puls je Zustand), CardView (text/image/list/map/code, Sci-Fi-Chrome), AriaViewCanvas (pannbare Flaeche, 2-Finger-Pan + Pinch, Karten materialisieren gestaffelt). WorkspaceScreen blendet die Flaeche als Overlay ueber Chat/Cockpit ein, sobald ARIA fuers fokussierte Projekt eine Ansicht komponiert. v1/Vorgeschmack, tsc-clean; Markdown=Klartext, Map=Marker-Liste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 03:22:08 +02:00
duffyduckandClaude Opus 4.8 3a8202d2de fix(f5tts): torch fest auf 2.6.0+cu124 pinnen (neuester cu124-Build)
Adaptiver Re-Pin scheiterte: f5-tts zieht torch 2.13.0, aber cu124-Wheels enden bei 2.6.0 (torch 2.7+ nur noch cu126+, was Treiber 550/CUDA12.4 nicht kann). Jetzt: torch/torchaudio 2.6.0+cu124 vor f5-tts installiert, Constraint-Datei haelt f5-tts vom Hochziehen ab. Treiber-Upgrade bleibt der strategische Fix fuer Voxtral/modernes CUDA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 02:25:12 +02:00
duffyduckandClaude Opus 4.8 7bbb75481c fix(f5tts): torch auf cu124 re-pinnen (Treiber 550/CUDA 12.4)
f5-tts>=1.0.0 zieht als Dependency ein neueres torch mit zu neuem CUDA-Build und ueberschreibt den cu121-Pin → 'NVIDIA driver too old (found 12040)' auf Treiber 550. Nach der Installation wird dieselbe torch/torchaudio-Version als cu124-Build force-reinstalled (--no-deps), kompatibel mit 550/CUDA 12.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 02:21:23 +02:00
duffyduck ee6c4f34db release: bump version to 0.2.3.1 2026-08-15 02:01:12 +02:00
duffyduckandClaude Opus 4.8 75daadf72d fix(xtts): GPU-Pinning an 8GB+12GB-Realitaet anpassen
Die zwei 3060 sind ungleich (GPU0=8GB, GPU1=12GB), nicht 12+12. Groesstes Modell (STT, spaeter Voxtral-STT-3B ~9GB) muss auf die 12GB-Karte: whisper->GPU1. TTS(F5)+LLM auf die 8GB-Karte: ->GPU0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:53:59 +02:00
duffyduckandClaude Opus 4.8 37aaa90239 fix(ai-box): Kernel-Header vor nvidia-driver (DKMS-Modulbau)
Ohne linux-headers ueberspringt DKMS den Modulbau ('No kernel headers were found') → nvidia-smi kann nicht mit dem Treiber reden. Jetzt: linux-headers-amd64 + linux-headers-$(uname -r) vor dem Treiber, plus 'dkms autoinstall' als Reparatur, falls nvidia-kernel-dkms schon ohne Header installiert war.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:49:35 +02:00
duffyduckandClaude Opus 4.8 03e6d784b6 fix(ai-box): fragilen apt-cache-Kandidat-Check raus, direkt installieren
Der apt-cache-policy|grep-Check meldete faelschlich 'kein Kandidat' (locale-/pipefail-fragil), obwohl nvidia-driver installierbar war. Ersetzt durch direkten Install als Test: apt-get install; bei Fehlschlag volles apt-get update + zweiter Versuch, erst dann Abbruch mit Quellen-Diagnose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:42:34 +02:00
duffyduckandClaude Opus 4.8 762f1a2dd9 fix(ai-box): Self-Heal fuer nvidia-driver-Kandidat (volles apt update)
Das schnelle 'apt-get update -qq' indiziert non-free gelegentlich nicht sauber (InRelease-Cache). Wenn kein Kandidat gefunden wird, erzwingt das Script jetzt ein vollstaendiges 'apt-get update' und prueft erneut, bevor es mit klarer Meldung abbricht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:38:45 +02:00
duffyduckandClaude Opus 4.8 49f6b26ab8 fix(ai-box): robuste non-free-Aktivierung + Treiber-Kandidat-Check
add_component ersetzt durch add_components: ergaenzt contrib/non-free/non-free-firmware in JEDER Components:-Zeile aller .sources-Dateien (ganze-Wort-Adressen, idempotent), verifiziert die Aenderung per md5sum statt sie nur zu melden. Vor dem nvidia-driver-Install wird der apt-Kandidat geprueft — bei fehlendem non-free klare Fehlermeldung + Anzeige der aktiven Quellen statt kryptischem 'kein Installationskandidat'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:31:08 +02:00
duffyduckandClaude Opus 4.8 c340b9d683 docs(ai-box): Stimm-Daten neu-enrollen statt kopieren
Alte Gamebox ist retired (RAM zur ai-box gewandert) → voice-id/voices lassen sich nicht mehr kopieren. Hinweis angepasst: ohne Fingerprint laeuft Speaker-ID fail-open, Stimme + F5-Referenz einfach in der App neu anlegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:24:26 +02:00
duffyduckandClaude Opus 4.8 a0429fc91e feat(ai-box): Bootstrap fuer die KI-Box (Debian Trixie)
Idempotentes Setup einer frischen Trixie-Box zum GPU-Satelliten-Host: non-free (deb822-Format), NVIDIA-Treiber, Docker+Compose, nvidia-container-toolkit, GPU-im-Container-Test, xtts/.env, optional Stack-Start (--up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 174d6d643d feat(voxtral): STT-Satellit (Profil) + 2-Karten-GPU-Pinning
Neuer xtts/voxtral-Container (RVS<->vLLM-Realtime-WS, adaptiver Endpointer), hinter Compose-Profil 'voxtral' (braucht >=16GB VRAM, startet nicht im Default). Bestehende Satelliten auf die zwei 3060 gepinnt: whisper->GPU0, f5tts->GPU1, llama-swap->GPU0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 fd6ba73f59 feat: GPS-Trigger-Kopplung + generatives Cockpit (aria_view)
GPS: near()/entered_near()/left_near()-Watcher schalten das Tracking automatisch an bzw. beim Loeschen des letzten wieder aus; gpsTracking.start() sichert jetzt die Background-Permission. Cockpit-Fundament: neues Brain-Tool present_view emittiert aria_view (Orb + Karten), Bridge/RVS leiten weiter, ariaView.ts haelt die Spec App-seitig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 f5b22253b2 fix(stt): adaptiver VAD-Schwellwert gegen Satz-Cutoff
Feste RMS-Grenze (0.012) durch rauschboden-relativen Schwellwert ersetzt (fast-down/slow-up, geklammert). Leises/entferntes Sprechen gilt nicht mehr faelschlich als Stille und wird nicht mitten im Satz gecuttet. audio.ts: Fallback-endpointMs 1500->2400 vereinheitlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 01:21:43 +02:00
duffyduckandClaude Opus 4.8 761f4c8903 fix(diagnostic): 'Sicher aufraeumen' ohne confirm() — Button feuerte nie
Ursache gefunden (WS-Direkttest beweist: Server-Pfad + Docker-Prune-API
funktionieren, aber der Klick kam nie an): runDiskCleanup() rief confirm()
VOR dem send(). Hatte der Browser Dialoge unterdrueckt ('Verhindern, dass
diese Seite weitere Dialoge erstellt' — bei dem vielen alert()/confirm()
im Diagnostic leicht passiert), gab confirm() automatisch false zurueck →
return vor send() → Button tat sichtbar nichts.

Fix: 'safe' laeuft OHNE confirm (Build-Cache + ungenutzte Images sind
ungefaehrlich, keine Volumes/Daten). Nur 'aggressive' (Volumes!) fragt
noch. Ergebnis wird am Button + Banner gezeigt, nicht nur per alert()
(das koennte genauso unterdrueckt sein).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 11:30:44 +02:00
duffyduck 091a1b7755 release: bump version to 0.2.3.0 2026-08-08 21:48:30 +02:00
duffyduckandClaude Opus 4.8 e2b1eced3c feat(logger): native App-Crashes ueber RVS erkennen (ohne adb)
Native Crashes/OOM schreiben keinen JS-Fehler → tauchten in app.log
nirgends auf (nur der Whisper-Reboot als Symptom). Jetzt: ein RUN_MARKER
bleibt gesetzt solange die App aktiv laeuft, wird bei sauberem Hintergrund-
Wechsel geloescht. Ist er beim naechsten Start noch da, ist der vorige Lauf
unsauber gestorben → Report via RVS ('app.crash-detected') mit dem letzten
Breadcrumb (was die App zuletzt tat) + Zeitabstand. Breadcrumbs kommen aus
reportAppError/reportAppDebug, throttled in AsyncStorage persistiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 16:51:18 +02:00
duffyduckandClaude Opus 4.8 fe804fa40e fix(chat): Nachrichten brechen nicht mehr ab (Bridge-Timeout) + Diagnostic-Selbstheilung
Ursache: Bridge-/chat-Aufruf hatte 1200s (20min) Timeout, der Proxy aber
24h. Lange Software-Dev-Turns dauern >20min → Bridge gab auf ('/chat
fehlgeschlagen: timed out'), der Brain lieferte die Antwort Minuten spaeter
(nur im Log, keine Bubble), und der Diagnostic-Kontext blieb auf 'running'
haengen (Folgenachrichten in die Queue trotz idler ARIA; nur Ctrl+R half,
verlor aber die Queue-Nachricht).

- Bridge: /chat-Timeout 1200s → 24h (env BRAIN_CHAT_TIMEOUT_SEC), passend
  zum Proxy.
- Diagnostic: reconcileDiagStates() gleicht lokalen 'running'-Zustand gegen
  die Brain-queue-status ab (2 Polls Karenz). Haengt ein Kontext, obwohl der
  Brain nicht busy ist → Queue weiterschalten: angestellte Nachricht geht
  automatisch raus statt verloren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 16:29:16 +02:00
duffyduckandClaude Opus 4.8 18eb94e942 fix(diagnostic): 'Sicher aufraeumen' fuehrt wirklich auf statt nur zu kopieren
Der Button rief copyDiskCmd() → kopierte nur den docker-Befehl in die
Zwischenablage (ueber http scheitert navigator.clipboard oft still →
'keine Funktion'). Jetzt fuehrt runDiskCleanup() das Aufraeumen echt aus:
Server-seitig ueber die Docker-Daemon-API (Socket ist gemountet) —
/build/prune?all=true + /images/prune (dangling=false), ohne Volumes =
keine Daten weg. 'Aggressiv' (zusaetzlich Container+Volumes) als eigener
'Jetzt ausfuehren'-Button mit Warnung. Rueckmeldung: wieviel frei wurde;
Banner aktualisiert sich per periodischem disk_status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:08:49 +02:00
duffyduckandClaude Opus 4.8 74c7ea0a2d fix(models): aktuelle Modell-Liste (Fable 5, Opus 5) im Picker
Der Sprachmodell-Picker zeigte veraltete Eintraege ("Opus 4.8", kein
Fable). Ursache: DEFAULT_MODELS/models.json waren stale. CLI-Tiers loesen
real auf fable→Fable 5, opus→Opus 5, sonnet→Sonnet 5, haiku→Haiku 4.5.
Jetzt Tier-Aliase als id (versions-robust) + Fable 5 ergaenzt; MODEL_MAP
kennt fable + die vollen aktuellen IDs. models.json auf dem Stack live
aktualisiert (wird pro Request neu gelesen → 'Aktualisieren' reicht).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 14:08:20 +02:00
duffyduckandClaude Opus 4.8 648e3b04fd feat(zwischenruf): Korrektur mitten in den laufenden Turn schieben
Neben Senden ein 'Zwischenruf' (App: oranger Button, nur wenn ARIA im
aktiven Kontext arbeitet; Diagnostic: Buttons neben beiden Senden). Geht
NICHT in die Queue und bricht NICHT ab — die Nachricht wird in den
laufenden claude-Subprozess geschoben; er greift sie an der naechsten
Tool-Grenze auf.

Technik:
- proxy-patches/manager.js: claude laeuft jetzt im --input-format
  stream-json-Modus, initialer Prompt als stream-json User-Message,
  stdin bleibt OFFEN; sendMessage() schiebt weitere User-Messages nach;
  bei 'result' wird stdin geschlossen (Turn endet sauber). Ersetzt die
  bisherigen sed-Patches (jetzt volle Datei via cp, docker-compose.yml).
- proxy-patches/routes.js: Side-Channel POST /interject {projectId,text}
  → subprocess.sendMessage der Kontext-Subprozesse.
- rvs: 'interject' erlaubt. bridge: RVS interject → Proxy /interject.
- App/Diagnostic: Zwischenruf-Buttons + lokale '📣 Zwischenruf'-Bubble.

Empirisch verifiziert: mid-turn injizierte Message wird an der naechsten
Tool-Grenze aufgegriffen (nicht mitten in einem blockierenden Befehl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 05:05:13 +02:00
duffyduckandClaude Opus 4.8 2ad8c2f245 fix(files): Bilder korrekt zuordnen bei Queue (clientMsgId-Korrelation)
Die App schickt Datei (fire-and-forget) und Text (ACK-getrackt, bei Queue
verzoegert) als getrennte RVS-Nachrichten; die Datei trug keine clientMsgId.
Die Bridge mergte gepufferte Files rein per Timing an den naechsten Text →
bei mehreren schnellen Nachrichten landeten Bilder beim falschen Text.

App: file-Send traegt jetzt dieselbe clientMsgId wie der Text.
Bridge: puffert Files mit cmid und merged beim Text-Flush NUR die Files mit
passender cmid; Rest bleibt fuer seine eigene Nachricht gepuffert. Fallback
(Legacy/kein Treffer) = altes Verhalten, damit nie ein Bild verloren geht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:41:14 +02:00
duffyduckandClaude Opus 4.8 85d190e98c fix(diagnostic): Abbrechen macht echten Cancel statt totem doctor --fix
Der cancel_request-Handler rief openclaw doctor --fix auf dem Container
aria-core auf, den es im aktuellen Stack nicht mehr gibt → der laufende
claude-Subprozess wurde nie gekillt, ARIA lief weiter. Jetzt sendet er
RVS cancel_request{hard:true} → Bridge → Proxy /cancel-all killt den Run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:41:14 +02:00
duffyduckandClaude Opus 4.8 70705269fd fix(brain): Trigger-Turn blockiert nicht mehr den Event-Loop
_fire() rief agent.chat() (synchroner, bis zu 24h blockierender Proxy-
Call) DIREKT im asyncio-Loop auf → ein feuernder Timer/Watcher fror den
GESAMTEN Brain ein (kein /health, kein weiterer Request, Self-Deadlock
wenn der Turn via brain_request auf den Brain zurueckgreift). Jetzt wie
der /chat-Pfad in run_in_executor ausgelagert; Event-Loop bleibt frei.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 02:31:47 +02:00
duffyduck 2aef0347ae release: bump version to 0.2.2.9 2026-07-21 01:21:23 +02:00
duffyduckandClaude Opus 4.8 a49c022308 fix(vnc): Tastatur-Button folgt echter Tastatur-Sichtbarkeit
Androids Zurueck-Taste versteckt die Tastatur ohne den TextInput zu
blurren → ⌨-Button blieb an, erst der uebernaechste Tap oeffnete wieder.
Jetzt setzt keyboardDidShow/Hide den Button-Zustand; Oeffnen erzwingt
blur→focus, damit ein noch fokussiertes Feld die Tastatur neu aufklappt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:19:37 +02:00
duffyduckandClaude Opus 4.8 2d3ba024a4 feat(vnc): Fn-Steuertasten-Leiste + Enter-Fix fuer VM-Bedienung
Die Software-Tastatur liefert weder Enter (Haken) noch Esc/Pfeile/F-Tasten/
Strg/Alt. Neu:
- Haken der Tastatur → onSubmitEditing sendet jetzt Enter (returnKeyType=send).
- Fn-Leiste (Toggle in der ctlBar): Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/
  Entf, Enter, F1–F12, Strg+Alt+Entf — direkt an rfb.sendKey.
- Sticky-Modifier Strg/Alt/Shift (one-shot): kombinieren mit der naechsten
  Taste ODER dem naechsten getippten Zeichen → Strg+C, Strg+Alt+Entf, Alt+F …
- noVNC: window.ariaVncKey.combo(mods,ks) haelt Modifier, tappt, laesst los.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:17:56 +02:00
duffyduck 1c7157327c release: bump version to 0.2.2.8 2026-07-21 01:10:26 +02:00
duffyduckandClaude Opus 4.8 6c27097c96 fix(app): gruener Desktop-Punkt nur bei laufender VM im aktiven Projekt
Badge haing bisher am globalen desktop_status (irgendwo ein Desktop
erreichbar) → wurde generell angezeigt. Jetzt aus der projektbezogenen
VM-Liste (listProjectVms → running), pid-Guard, 6s-Refresh fuer Start/Stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:08:37 +02:00
duffyduckandClaude Opus 4.8 a91325a04f fix(vnc): Tastatur ueber echtes RN-TextInput statt WebView-Hidden-Input
Der versteckte WebView-Input oeffnete die Android-Software-Tastatur
unzuverlaessig. Jetzt haelt VncTile ein echtes RN-<TextInput>
(keyboardType=visible-password), der ⌨-Button fokussiert es. Getippte
Zeichen gehen per injectJavaScript an window.ariaVncKey.char/keysym →
rfb.sendKey. Prefix-Diff fuer Druckbares, onKeyPress fuer Backspace/Enter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:06:27 +02:00
duffyduck ef826d1ed1 release: bump version to 0.2.2.7 2026-07-21 01:01:54 +02:00
duffyduckandClaude Opus 4.8 933836f0a6 fix(app): kein 404 bei Dateien/Desktop im Hauptchat (leere project_id)
Im Hauptchat (projectId='') riefen FilesTile/DesktopTile /projects//files bzw.
/vms auf → Brain 404. Beide guarden jetzt leere projectId: kein Fetch, klare
Meldung "Kein aktives Projekt — wechsle in ein Projekt".

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:59:50 +02:00
duffyduck e04d8f360b release: bump version to 0.2.2.6 2026-07-21 00:54:02 +02:00
duffyduckandClaude Opus 4.8 4685632294 feat(vnc): echtes Vollbild beim Verbinden (randlos ueber das ganze Display)
Der ⤢-Knopf war nur der Fit/1:1-Umschalter (Zoom) INNERHALB des eingerahmten
Streifens (Header+Dock drumrum) — sah deshalb wie Zoom aus, fuellte nie das
Display. Jetzt oeffnet "Verbinden" die VM in einem Fullscreen-Modal: randlos
ueber alles (Header + Dock weg), scaleViewport passt den VM-Screen sauber ein,
Landscape erlaubt. Buttons schweben drueber (‹ VMs links, ⌨/Strg+Alt+Entf/⤢
rechts, unter der Statusleiste). Android-Back verlaesst Vollbild.

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:51:59 +02:00
duffyduck 769025c41b release: bump version to 0.2.2.5 2026-07-21 00:47:46 +02:00
duffyduckandClaude Opus 4.8 2005e9b85e fix(vm): Screenshot ohne /root-Rechte + VNC-Tastatur robuster
Screenshot: aria-vm lief als User 'aria', SHOT_DIR war aber /root/... →
"mkdir: cannot create directory /root". Jetzt schreibt aria-vm das PNG ins
VM-Verzeichnis (aria-schreibbar) und der Brain holt es per SSH (base64,
_ssh_host) — unabhaengig von Volume-Rechten. End-to-end validiert (gueltiges
PNG). Wird weiter ins Projekt (screenshots/) kopiert.

VNC-Tastatur: verstecktes Input-Feld war off-screen (opacity:0, left:-1000px) →
Android oeffnete die Tastatur oft nicht / lieferte keine Events. Jetzt on-screen
(bottom, 1px, opacity:0, pointer-events:none) + beforeinput als primaerer
Handler (Android-robust) mit input-Fallback. Strg+Alt+Entf ging schon.

py/bash/tsc clean. aria-vm bereits live. Deploy: brain rebuild + APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:45:41 +02:00
duffyduckandClaude Opus 4.8 25abd220ad fix(vnc): Bridge nutzt aria-net-Gateway statt host.docker.internal (VNC refused)
Kern des "Verbinde..."-Hangs: host.docker.internal loeste zu 172.17.0.1 (docker0)
auf, QEMUs VNC ist aber ans aria-net-Gateway 192.168.64.1 gebunden (dorthin
bindet der Brain) → Connection refused. Die Bridge ermittelt den VNC-Host jetzt
selbst per _docker_gateway() (/proc/net/route) = dasselbe Gateway wie der Brain.
ARIA_VNC_HOST-Env hat weiter Vorrang.

Live belegt: Bridge->192.168.64.1:5901 liefert RFB-Handshake, ->host.docker.
internal:5901 (172.17.0.1) = refused.

py_compile clean. Deploy: bridge rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:35:26 +02:00
duffyduck 3c6bf0783e release: bump version to 0.2.2.4 2026-07-21 00:25:19 +02:00
duffyduckandClaude Opus 4.8 2832ab3dc9 fix(vm): global eindeutige VNC-Displays (kein Doppel-Binding) + Gateway per-boot
- project_vms.add_vm: VNC-Display wird GLOBAL eindeutig vergeben (ueber alle
  Projekte). vnc_display<=0 oder belegtes Display → naechstes freies. Updates
  behalten ihr Display. Verhindert Port-Konflikt (5901) wenn mehrere VMs laufen.
  Getestet: vm1..3 → 1,2,3; Wunsch 2 (belegt) → 4; Update behaelt Display.
- vm_register-Tool + Seed-Regel: vnc_display weglassen, wird auto-vergeben.
- Gateway-IP: der Brain liest sie aus /proc/net/route bei JEDEM Boot frisch
  (Docker-IP-Aenderung nach Netz-Neuaufbau wird abgefangen); boot-Antwort
  enthaelt vnc_bind zur Transparenz.

py_compile clean. Brain-only, Deploy: brain rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:23:23 +02:00
duffyduckandClaude Opus 4.8 d38d62ba21 fix(vnc): Bridge erreicht QEMU-VNC (Kern-Bug) + Screenshots ins Projekt
Live-VNC rendert nie, weil die Bridge die VM nie erreichte: QEMU band 127.0.0.1
(Host-Loopback) und host.docker.internal resolved in der Bridge gar nicht.
- aria-vm: --vnc-bind <ip> (Default 127.0.0.1 / env ARIA_VM_VNC_BIND).
- Brain: _docker_gateway() (aus /proc/net/route) → boot bindet VNC an die
  Docker-Gateway-IP (container-erreichbar, NICHT im LAN/Internet).
- docker-compose: bridge bekommt extra_hosts host.docker.internal:host-gateway.
- Screenshot-Endpoint kopiert das PNG zusaetzlich nach
  /shared/projects/<pid>/screenshots/ → erscheint im Dateien-Panel.

End-to-end auf dem Host validiert: Bridge liest den RFB-Handshake
(b'RFB 003.008\n') von der Gateway-gebundenen VNC. aria-vm bereits live.
py/bash clean. Deploy: brain rebuild + bridge NEU (extra_hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:20:21 +02:00
duffyduckandClaude Opus 4.8 5890c17ec0 feat(app): Dateien-Panel im Dock (zwischen Chat und Code)
Neues 📁-Panel listet ALLE Projektdateien (/shared/projects/<id>/) — auch
erzeugte Bilder, nicht nur Code; dieselben, die in der Projektliste als 📄
gezaehlt werden. Bild antippen → Vollbild-Vorschau; Textdatei → Text-Vorschau.
- Brain: /projects/<id>/file?binary=1 → Base64 + MIME (fuer Bilder, max 8 MB).
- App: brainApi.readProjectFileBinary; layout 'files'-Tile; Dock-Reihenfolge
  Chat · Dateien · Code · Desktop; FilesTile mit Bild-/Text-Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:09:34 +02:00
duffyduckandClaude Opus 4.8 239f1094f9 feat(vm): Screenshot pro VM im Desktop-Panel (VM sehen ohne Live-VNC)
Solange das Live-VNC-Bild noch hakt, ist ein Standbild der pragmatische Weg die
VM zu sehen — genau wie ARIA es beim basic_os-Test gemacht hat.
- Brain: POST /projects/<pid>/vms/<name>/screenshot → aria-vm screenshot (PNG in
  /shared/uploads) → als Base64 zurueck. End-to-end auf dem Host validiert
  (Brain-Container sieht die Datei unter /shared/uploads).
- App: 📷-Knopf pro laufender VM im DesktopTile → zeigt den Screenshot im Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:55:47 +02:00
duffyduckandClaude Opus 4.8 055db7c059 feat(vm): Medien pro VM (disk/floppy/iso) + ARIA-Urteilswissen
vm_register + Registry + boot fassen jetzt das ECHTE Boot-Medium (disk/floppy/
iso) — Eintrag, Startbefehl und App-Start spiegeln die reale Config. Leer =
aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso selbst.
- project_vms.add_vm: floppy/disk-Felder.
- main.py: VmAddBody + _vm_boot_args (baut --disk/--floppy/--iso), boot-Endpoint
  + boot_cmd nutzen sie.
- agent.py: vm_register-Tool bekommt disk/floppy/iso.
- Seed-Regel: URTEIL — VM nur wenn sinnvoll; Medium aus der Situation (Festplatte
  fuer DOS-Spiele, Diskette fuer OS-Dev, ISO fuer Installer); Architektur zum Task
  (ARM=aarch64, emuliert/langsam ok); Aenderungs-Zyklus stop→aendern→boot.

py_compile clean. aria-vm schon live auf dem Host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:51:27 +02:00
duffyduckandClaude Opus 4.8 2a8cbc6c15 fix(vm): aria-vm boot flexibel (Diskette/ISO ohne Disk) + ARIA-Grundwissen
Start aus der App scheiterte: aria-vm boot verlangte zwingend disk.qcow2 —
basic_os bootet aber von floppy.img/ISO ohne Festplatte (OS-Bau). Jetzt:
- aria-vm boot: Disk optional; --floppy (-fda), --iso, --disk; Auto-Erkennung
  von disk.qcow2/floppy.img/cdrom.iso im VM-Ordner; Boot-Reihenfolge automatisch
  (ISO→d, nur Diskette→a, sonst c); braucht nur >=1 Boot-Medium.
- aria-vm create: groesse='none' → VM ohne Disk.
- Seed-Regel (Grundwissen) neu: volle aria-vm-Optionen inkl. disk-los, PFLICHT
  vm_register nach Bau (sonst leere Desktop-Liste), Dateien in /shared/projects,
  VNC komplett durch RVS. So weiss ARIA selbst was zu tun ist.

Live getestet auf dem Host: boot basicos (floppy-Autodetect) → laeuft :5901.
aria-vm bereits auf den Host gepusht. bash -n / py_compile OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:48:22 +02:00
duffyduck 7e14107361 release: bump version to 0.2.2.3 2026-07-20 23:34:05 +02:00
duffyduckandClaude Opus 4.8 e64043dca5 feat: Datei-Symbol am Projekt (auto) + VM-Startparameter im Desktop-Panel
- Brain: /projects/status + /list liefern has_files + file_count pro Projekt
  (Scan /shared/projects/<id>/). Ersetzt das manuelle Code-Flag als primaeren
  Indikator. VM-Liste liefert boot_cmd (lesbarer aria-vm-Startbefehl).
- App: 📄-Symbol (+ Anzahl) an Projekten mit Dateien im ProjectsBrowser.
  DesktopTile zeigt pro VM den Start-Befehl als Wert dahinter.
- Diagnostic: 📄-Symbol (+ Anzahl, Tooltip) an Projekten mit Dateien.

Hinweis (kein Code): der VNC-Stream laeuft komplett durch RVS — der Port ist nur
der interne QEMU-Display-Port, den die Bridge lokal auf dem Host nutzt; die App
oeffnet nie einen Port (firewall-unabhaengig).

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:31:31 +02:00
duffyduckandClaude Opus 4.8 0efb8d0848 feat: VM-Liste pro Projekt (Desktop-Panel) — Start/Stop/Verbinden
- Brain: project_vms.py (Registry /shared/config/project_vms.json pro Projekt) +
  Endpoints GET/POST/DELETE /projects/<id>/vms + boot/stop (via SSH aria-wohnung
  aria-vm auf dem Host; Status aus `aria-vm list`).
- ARIA-Tools vm_register/vm_list (aufs Request-Projekt) + Seed-Regel: nach dem
  VM-Bau registrieren, damit sie in Stefans Desktop-Panel auftaucht.
- App: brainApi VM-Methoden + ProjectVm-Typ. Neues DesktopTile — pro Projekt die
  VM-Liste (leer bis registriert), Start/Stop/Verbinden; Verbinden oeffnet noVNC
  (VncTile mit dem VNC-Port der VM). Deck rendert DesktopTile statt VncTile.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:25:55 +02:00
duffyduckandClaude Opus 4.8 5fa5d79ad8 feat: Editor laedt vorhandene Dateien + manueller Code-Toggle (App + Diagnostic)
Editor zeigte "keine Datei", obwohl ARIA schon Dateien geschrieben hatte — er las
NUR den Live-code_file-Stream, nie den Bestand. Jetzt:
- Brain: GET /projects/<id>/files + /file (liest /shared/projects/<id>/, pfad-sicher,
  512KB-Cap). kind in ProjectUpdateBody (PATCH akzeptiert 'code'|'chat').
- App: brainApi.listProjectFiles/readProjectFile/setProjectKind. CodeEditorTile
  holt beim Oeffnen die vorhandene Dateiliste + laedt Inhalt (Live-Version hat
  Vorrang). ProjectsBrowser-Edit: Code-Projekt-Toggle (spiegelt sofort in
  projectFocus → Cockpit-Panels).
- Diagnostic: </> Code-Toggle je Projektzeile + Code-Badge.

py/node/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:20:39 +02:00
duffyduckandClaude Opus 4.8 9dd7a2cb14 fix(satellite): install.ps1 — richtiges Cmdlet New-ScheduledTaskSettingsSet
New-ScheduledTaskSettings existiert nicht (CommandNotFound). Korrekt ist
New-ScheduledTaskSettingsSet. venv/Requirements liefen bereits; nur die
Task-Registrierung brach ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:07:07 +02:00
duffyduckandClaude Opus 4.8 2811379684 feat(satellite): Dienst-Installer install.sh (Linux/macOS) + install.ps1 (Windows)
Richten den Satelliten als Autostart-Dienst ein (venv + requirements + Restart):
- install.sh: Linux → systemd-Unit (aria-satellite), macOS → launchd-Agent.
  `bash install.sh` / `bash install.sh uninstall`.
- install.ps1: Windows → Scheduled Task (S4U, AtStartup+AtLogOn, Restart, hidden),
  loggt nach satellite.log. Admin-Check. `-Uninstall` entfernt.
- WorkingDirectory = satellite/ (findet .env). README-Abschnitt ergaenzt.

install.sh bash -n OK; install.ps1 manuell geprueft (kein pwsh lokal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:02:30 +02:00
duffyduckandClaude Opus 4.8 902316566e fix(satellite): .env-Parser schneidet Inline-Kommentare ab
Nativer Start crashte an `SCAN_INTERVAL_SEC=300      # Hintergrund-Rescan-Intervall`
(int('300      # ...') → ValueError), weil der .env-Parser den Wert ungekuerzt
nahm. Jetzt: ungequotete Werte werden am ersten " #" (Whitespace+#) abgeschnitten,
gequotete Werte bleiben unangetastet (auch mit # im Inhalt). Getestet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:57:05 +02:00
duffyduckandClaude Opus 4.8 1f94b4eab5 fix(satellite): laedt .env selbst beim nativen Start
Nativ gestartet (python satellite.py) las das Script nichts aus der .env — nur
os.environ. Die Variablen mit Default (SATELLITE_ID, CONTROL_ENABLED …) wirkten
"ok", aber RVS_HOST/RVS_TOKEN (ohne Default) blieben leer → Verbindungsfehler.
Jetzt laedt _load_dotenv() eine .env neben dem Script (oder im CWD), bevor die
Config gelesen wird. Bestehende echte Umgebungsvariablen gewinnen (Docker via
env_file bleibt unberuehrt). Kein python-dotenv noetig. README Weg B vereinfacht.

Getestet: Parser liest Keys inkl. Quotes + export-Praefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:55:04 +02:00
duffyduckandClaude Opus 4.8 57e13800e0 fix(satellite): Selbstdiagnose fuers Netz — warnt bei Docker-/NAT-Netz
Symptom: Satellit fand nur Docker-Container (192.168.65.x / 172.18.x) statt der
echten LAN-Geraete. Ursache ist kein Bug, sondern das Deployment-Netz: auf Docker
Desktop (Mac/Windows) ist network_mode:host das Docker-VM-NAT, nicht das echte LAN
— mDNS/SSDP erreichen die realen Geraete nicht.

- satellite.py: _net_context() ermittelt primary_ip + alle IPs und WARNT, wenn der
  Satellit in einem Docker-/NAT-Netz laeuft (192.168.65.x oder 172.16-31.x). Netz-
  Info wird in sat_hello + sat_devices mitgeschickt und beim Start geloggt.
- Diagnostic: zeigt Netz (primary_ip) pro Satellit + eine rote ⚠-Box mit der
  Warnung, wenn er im falschen Netz sitzt.
- README/compose: klar dokumentiert, dass der Satellit im ECHTEN Ziel-LAN laufen
  muss (Linux Docker Engine ODER nativ python satellite.py); Docker-Desktop-Falle.

py/node clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:46:34 +02:00
duffyduckandClaude Opus 4.8 1dc8c0936f feat(diagnostic): Satelliten-Tab — verbundene Satelliten + erkannte Geraete
Das Diagnostic haengt selbst am RVS-Raum und kennt die sat_*-Broadcasts jetzt:
- server.js: Satelliten-Registry (sat_hello), leitet sat_devices an den Browser,
  gibt die Liste beim init mit; Browser-Aktionen sat_list + sat_discover.
- index.html: neuer Haupt-Tab "Satelliten" — Karten pro Satellit (online/offline,
  Standort, Capabilities, read-only/steuerbar) mit "Geraete scannen" → Live-Liste
  der erkannten Geraete (Typ/IP/Modell/DIAL/MAC).

Kein Brain/Bridge-Rebuild fuer diesen Teil noetig (nur diagnostic). node -c OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:11:14 +02:00
duffyduckandClaude Opus 4.8 071e38f464 feat: Satelliten — ARIAs Augen & Haende in fremden Netzen (Info-/Gateway-Aussenposten)
Neuer eigenstaendiger Container satellite/ (RVS-Client, network_mode host), den
man in einem beliebigen Netz (Buero etc.) deployt. Gibt ARIA Zugriff auf dieses
Netz ohne Haupt-Stack davor.

- satellite/satellite.py: RVS-Client + Discovery (mDNS/Zeroconf, SSDP/UPnP+DIAL,
  ARP) + Steuerung (dial.launch fuer YouTube-auf-FireTV, wol, http) mit Guards
  (CONTROL_ENABLED + Allowlist + Logging, token-gated, keine offenen Ports).
  Periodisches Re-Announce (sat_hello im Heartbeat) fuer spaet joinende Bridge.
- satellite/: Dockerfile, requirements, docker-compose (host-net), .env.example
  (SATELLITE_LOCATION als Adresse), README.
- rvs: sat_hello/discover/devices/command/result whitelisted.
- bridge: Satelliten-Registry (sat_hello) + Future-Relay (_satellite_request) +
  /internal/satellite + /internal/satellite-list (Muster wie flux).
- brain: Tools satellite_list/devices/command + _dispatch_satellite + Seed-Regel.

Alle py_compile + node -c gruen. Kein APK-Rebuild noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 23:22:30 +02:00
duffyduck 28361ea97a release: bump version to 0.2.2.2 2026-07-17 22:57:49 +02:00
duffyduckandClaude Opus 4.8 a20e57e33a fix(brain): project_summary liest volle Projekt-Historie aus chat_backup.jsonl
"Hol dir die Infos aus Projekt X" lieferte fast immer leer: project_summary las
nur das rollende Conversation-Window (~50 Turns ueber alle Projekte), aeltere
Projekt-Chats sind da rausdistilliert. Jetzt liest _read_project_history die
echte volle Historie aus /shared/config/chat_backup.jsonl (im Brain gemountet),
letzte ~20 Turns des Zielprojekts, Standort-Hints gefiltert, Fallback aufs
Window. Tool-Beschreibung geschaerft. Live gegen echte Daten getestet
(basic_os 20 / vdi 20 / mac_os_update_fehler 6 Turns).

Kein APK-Rebuild noetig (nur Brain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:55:54 +02:00
duffyduckandClaude Opus 4.8 dc775ee34f feat: geraetelokaler Voice-Projektwechsel ("geh in Projekt X")
Der Voice-Projektwechsel ist zurueck, aber nur fuer das ausloesende Geraet:
- App merkt sich die IDs eigener Anfragen (Text-clientMsgId via dispatchWithAck +
  Voice-audioRequestId an allen 4 Aufnahme-Stellen) in myRequestIdsRef.
- Bridge haengt an project_changed die ausloesende clientMsgId an — in beiden
  Voice-Pfaden: send_to_core (ARIAs project_enter/exit) und _process_endpoint_text
  (Voice-Router back_to_main / project_prefix).
- App folgt einem project_changed-Wechsel nur, wenn die clientMsgId eine eigene
  ist → andere App-Instanzen + Diagnostic bleiben unberuehrt.

Nur Bridge + App betroffen. py-compile + tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:47:38 +02:00
duffyduckandClaude Opus 4.8 c2122c43c2 fix: Projekt-Fokus pro Geraet unabhaengig (Diagnostic/App/Multi-Instanz)
Zwei Kopplungs-Bugs behoben:
- set_project_kind nutzte den globalen active_project statt des Request-Projekts
  → markierte das falsche Projekt (belegt: global aktiv war mac_os_update_fehler,
  das kind=code bekam, obwohl die App in basic_os war). _dispatch_tool bekommt
  jetzt die project_id des Requests durchgereicht; set_project_kind wirkt darauf.
- Die App erzwang bei project_changed-Broadcasts (ARIA-Tool/Diagnostic/andere
  App-Instanz) einen Fokuswechsel → alle Geraete wurden mitgezogen. Fokus ist
  jetzt rein geraetelokal; Broadcasts aktualisieren nur Namen/Typ. Wechseln nur
  noch lokal ueber den Drawer.

py-compile + tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:37:51 +02:00
duffyduck f45d42cd30 release: bump version to 0.2.2.1 2026-07-17 21:47:18 +02:00
duffyduckandClaude Opus 4.8 0110526622 feat(app): Workbench-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole (0.2.2.0)
Cockpit neu gedacht — das Pinch-Zoom-Landkarten-Konzept war auf 5 Zoll fummelig:
- WorkspaceDeck + WorkspaceDock: Taskleiste unten (Chat · Code · Desktop),
  Ein-Tap-Panelwechsel im Daumenbereich, animierter Indikator, Aktivitaets-
  Badges (Code blau / Desktop gruen), Safe-Area, blendet bei offener Tastatur
  aus. Panels bildschirmfuellend + immer gemountet (kein Remount).
- noVNC bedienbar: novncHtml bekommt Tastatur-Bridge (verstecktes Input-Feld →
  rfb.sendKey inkl. Enter/Backspace/Pfeile), Strg-Alt-Entf, Fit↔1:1. VncTile
  zeigt eine Steuerungs-Leiste (⌨ / Strg+Alt+Entf / ⤢).
- Entfernt: WorkspaceCanvas, Tile, PreviewTile, CockpitOverviewButton,
  cockpitNav (Pinch-Canvas + Uebersichts-Button obsolet). layout.ts auf
  Panel-Metadaten geschlankt. Header nur noch mit Kompakt/Cockpit-Umschalter.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:44:32 +02:00
duffyduck 7d46d8e531 release: bump version to 0.2.2.0 2026-07-17 14:49:33 +02:00
duffyduckandClaude Opus 4.8 b839b059d0 fix(app): Cockpit — alle Kacheln sichtbar + Uebersicht-Button in den Header
Zwei Nachbesserungen am Cockpit-Modus (noch Teil von 0.2.1.9):
- Im Cockpit werden IMMER alle vier Kacheln gezeigt (Chat/Editor/Desktop/
  Vorschau) statt erst bei Code-Projekten — Editor/Desktop/Vorschau als
  Platzhalter mit Status-Untertitel. Vorher wirkten sie "verschwunden".
- Der "⤢ Uebersicht"-Button hing im Chat genau ueber dem Abbrechen-Button der
  "ARIA denkt"-Leiste. Jetzt sitzt er im Navigations-Header links (nur sichtbar
  im Cockpit + Fokus), via neuem cockpitNav-Signal-Singleton. In-Content-Button
  entfernt.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:46:49 +02:00
duffyduck 9950cdea87 release: bump version to 0.2.1.9 2026-07-17 00:35:01 +02:00
duffyduckandClaude Opus 4.8 7329257830 feat(app): Kompakt ↔ Cockpit — Ansichts-Umschalter im Header (0.2.1.9)
Nach dem 0.2.1.8-Deploy sah die App "unveraendert" aus — korrekt, weil der
Hauptchat nur eine Kachel hat (= Vollbild-Chat). Jetzt explizit umschaltbar:
- services/viewMode.ts: 'compact' | 'cockpit', persistiert (aria_view_mode),
  Default 'compact' (nichts aendert sich fuer normale Nutzung).
- ViewModeToggle im Navigations-Header (rechts, kollisionsfrei): "⧉ Kompakt" /
  "⧉ Cockpit".
- WorkspaceScreen: compact → klassische ChatScreen direkt; cockpit → Canvas.
- Canvas: Uebersicht/Gesten/Back jetzt auch bei einer Kachel erreichbar (der
  single-Force-Fokus entfaellt), damit sich Cockpit auch im Hauptchat wie ein
  Desktop anfuehlt. "⤢ Uebersicht"-Button nach unten rechts verschoben (weg von
  ChatScreens Kopf-Icons).

Changelog: Workspace-Release als 0.2.1.8 gefuehrt, Umschalter als 0.2.1.9.
tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:33 +02:00
duffyduck bbfd544013 release: bump version to 0.2.1.8 2026-07-17 00:12:38 +02:00
duffyduckandClaude Opus 4.8 aadc030407 docs(readme): Installations-Schritt 6 — QEMU-Host-Provisionierung
qemu-setup.sh laeuft NICHT ueber docker compose (QEMU sitzt auf dem Host, nicht
im Container). Neuer optionaler Schritt 6 im Installations-Abschnitt: einmalig
`sudo bash host-provisioning/qemu-setup.sh` fuer den Desktop-Workspace (VM-Bauen/
VNC), plus Hinweis auf den noetigen App-Rebuild (neue native Module).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:09:30 +02:00
duffyduckandClaude Opus 4.8 85756a161b docs: Desktop-Workspace dokumentieren + Version 0.2.2.0
CHANGELOG-Eintrag [0.2.2.0] (Canvas, Live-Editor, QEMU/VNC), README-Features +
Completed-Log erweitert, App-Version 0.2.1.7 → 0.2.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:05:38 +02:00
duffyduckandClaude Opus 4.8 ea3de70d05 feat(app): Workspace-Umbau Schritt 8 — Live-Reveal + Layout-Persistenz
- Progressive Reveal: ChatScreen spiegelt bei project_changed den Projekt-Typ
  sofort in projectFocus (kind_changed nach set_project_kind) → Editor/Desktop-
  Kacheln erscheinen live, nicht erst beim Reconnect.
- useWorkspaceLayout: merkt pro Projekt die zuletzt fokussierte Kachel
  (AsyncStorage aria_workspace_layout) und stellt sie beim Zurueckkehren in ein
  Code-Projekt wieder her.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:04:03 +02:00
duffyduckandClaude Opus 4.8 0b35ea9bde feat: Workspace-Umbau Schritt 7 — VNC-Live-Desktop durch RVS getunnelt
QEMU-VNC erscheint live in der App-Desktop-Kachel, komplett ueber RVS:
- Bridge: TCP↔RVS-VNC-Bruecke. vnc_open oeffnet asyncio-Verbindung zu
  host.docker.internal:5901, Reader-Loop streamt RFB-Bytes als vnc_data;
  vnc_input schreibt zurueck; vnc_close raeumt auf. check_desktop probt den
  Port und meldet desktop_status. Base64-in-JSON, kein websockify/noVNC auf
  dem Host noetig.
- App: novncHtml.ts laedt noVNC (CDN) und ersetzt window.WebSocket durch einen
  Shim, der RFB-Bytes per postMessage ueber RVS brueckt (server-speaks-first →
  timing-robust). VncTile mountet die WebView nur im Fokus, oeffnet bei 'ready'
  den Tunnel (desktop.openVnc), speist Server-Bytes ein und schickt Eingaben
  als vnc_input; beim Verlassen wird der Tunnel geschlossen (VM laeuft weiter).

tsc/py clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:02:04 +02:00
duffyduckandClaude Opus 4.8 a082c8398e feat: Workspace-Umbau Schritt 6 — QEMU fuer alle Architekturen + set_project_kind
QEMU auf dem Host + ARIA-Anbindung:
- host-provisioning/qemu-setup.sh: installiert qemu-system-* (x86/arm/mips/ppc/
  sparc/misc), qemu-utils, Firmware, socat, imagemagick + den aria-vm-Helper.
- host-provisioning/aria-vm: VM-Verwaltung fuer JEDE Architektur (create/boot/
  screenshot/list/stop/rm). VNC bindet nur 127.0.0.1:<display> (Tunnel via
  Bridge), KVM nur fuer x86-Gaeste, sonst TCG.
- Brain: Projekt-Modell bekommt kind ('chat'|'code'); neues Tool
  set_project_kind markiert das aktive Projekt (blendet Editor/Desktop in der
  App ein) + feuert project_changed.
- Seed-Regel: ARIA weiss jetzt, dass Code unter /shared/projects/<id>/ gehoert
  und wie sie per `ssh aria-wohnung aria-vm ...` VMs baut/testet; VNC landet
  automatisch in der Desktop-Kachel.

py-compile clean, Host-Skripte bash -n OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:57:57 +02:00
duffyduckandClaude Opus 4.8 a6cb152f55 feat: Workspace-Umbau Schritt 5 — Live-Code-Editor + code_file-Strom
Durchgaengiger Live-Editor fuer Code-Projekte:
- App: selbstenthaltener Highlight-Editor in einer WebView (editorHtml.ts,
  Textarea + Regex-Highlight-Layer, voll offline). CodeEditorTile mit Datei-Tabs,
  verdrahtet mit dem codeFile-Spiegel; Bridge-Protokoll setContent/applyPatch/
  setReadOnly ↔ onEditFromUser.
- Proxy-Hook (routes.js): faengt ARIAs Write/Edit/MultiEdit unter
  /shared/projects/<pid>/ ab und postet den Volltext bei Erfolg an
  /internal/code-file. tool_use_id→file_path-Korrelation, liest die Datei aus
  dem gemounteten /shared.
- Bridge: /internal/code-file relayt als RVS code_file an die App; eingehende
  code_file_edit schreiben den Volltext pfad-sicher nach /shared/projects/<pid>/
  (_write_project_file, kein Ausbruch via ..).

Arbeitsverzeichnis fuer Code-Projekte = /shared/projects/<projectId>/ (in proxy/
bridge/brain gemountet, kein SSH noetig). tsc/py/js clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:53:48 +02:00
duffyduckandClaude Opus 4.8 85363b1014 feat(app): Workspace-Umbau Schritt 4 — zoombarer Canvas + Fokus/Uebersicht
Der Chat-Tab hostet ab jetzt den Workspace-Canvas (eine ChatScreen-Instanz,
kein Doppel-Mount). Zwei Ebenen:
- Welt-Ebene (reanimated scale/translate): leichte Thumbnail-Kacheln, 2-Finger
  Pinch-Zoom + 2-Finger-Pan nur in der Uebersicht (gesture-handler).
- Identity-Content-Ebene (Scale 1): schwere Inhalte (ChatScreen + kommende
  WebViews) immer gemountet, nur die fokussierte per display sichtbar → Touch/
  Keyboard bleiben korrekt, nichts remountet beim Fokuswechsel.

Tap auf Kachel = Fokus (voll interaktiv), "⤢ Uebersicht"/Hardware-Back = zurueck
zur Landkarte. Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert
→ verhaelt sich exakt wie der bisherige Vollbild-Chat.

Neue Dateien unter android/src/workspace/: layout.ts (Kamera-Mathe, Center-
Origin fuer RN 0.73), WorkspaceCanvas.tsx, WorkspaceScreen.tsx, Tile.tsx,
tiles/{ChatTile,CodeEditorTile,VncTile,PreviewTile}.tsx. Editor/VNC sind noch
Platzhalter (Commit 5/7). tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:48:04 +02:00
duffyduckandClaude Opus 4.8 20c527c8ed feat: Workspace-Umbau Schritt 3 — Backend-Kanaele + code/desktop-Services
Verdrahtet die neuen Kanaele "dunkel" (noch ohne UI):
- rvs/server.js: ALLOWED_TYPES um code_file/code_file_edit, check_desktop/
  desktop_status und vnc_open/close/data/input erweitert (Base64-in-JSON-Relay
  wie audio_pcm, kein Binaer-Handling noetig).
- brainApi.ts: Project.kind ('code'|'chat') + desktop_url; ChatScreen publiziert
  die Kinds in den projectFocus-Spiegel.
- services/codeFile.ts: Live-Spiegel der Code-Dateien (content + Deltas) mit
  Ruckkanal code_file_edit fuer eigene Edits.
- services/desktop.ts: Desktop-Status + VNC-RFB-Tunnel (vnc_open/close/input/
  data) durch RVS, Session = Projekt-ID.

tsc clean, rvs/server.js syntaktisch OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:40:17 +02:00
duffyduckandClaude Opus 4.8 80b534cbab feat(app): Workspace-Umbau Schritt 2 — projectFocus-Spiegel
Neues Singleton services/projectFocus.ts (Publish/Subscribe wie rvs.ts): haelt
focusedProjectId, Projekt-Namen und Projekt-Kind. ChatScreen publiziert Focus +
Namen EINWEG hinein (rein additive Effekte), damit der kommende Workspace-Canvas
den aktiven Kontext und den Code-Projekt-Status kennt, ohne dass ChatScreen den
Workspace kennt oder umgebaut wird. Verhaltensneutral, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:36:09 +02:00
duffyduckandClaude Opus 4.8 747c67766c feat(app): Workspace-Umbau Schritt 1 — gesture-handler/reanimated/webview + Root-Wrapper
Fundament fuer den zoombaren Desktop-Workspace:
- react-native-gesture-handler@2.14.1, react-native-reanimated@3.6.2,
  react-native-webview@13.6.4 (kompatibel mit RN 0.73.4 / Hermes / old-arch)
- babel.config.js: reanimated/plugin als letztes Plugin
- index.js: gesture-handler als allererster Import
- App.tsx: GestureHandlerRootView um den gesamten Baum

Hinweis: nach npm install einmalig `npm start --reset-cache` + nativer Rebuild
(gradlew clean), sonst brechen die reanimated-Worklets still.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:09:59 +02:00
duffyduck c92e042e91 release: bump version to 0.2.1.7 2026-07-14 23:20:57 +02:00
duffyduckandClaude Opus 4.8 019b17ff97 fix(app): Queue friert nach Verbindungsabbruch nicht mehr ein (Issue 1)
Zwei Ursachen:
- pending_queue-Bubbles haben (noch) keine clientMsgId → der Reconnect-
  History-Sync erkannte sie nicht als lokal-only und verwarf sie, waehrend
  projectQueues den Eintrag behielt → 'N in Warteschlange' fror ein ohne
  sichtbare Nachricht. Jetzt bleiben pending_queue-Bubbles beim Sync erhalten.
- Watchdog: haengt ein Kontext >15s auf 'running', obwohl der Brain ihn NICHT
  als busy meldet (Antwort beim Abbruch verloren), schaltet die Queue jetzt
  selbst weiter (dequeue oder idle) statt fuer immer zu blockieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:18:06 +02:00
duffyduckandClaude Opus 4.8 55938c3173 fix(brain): Trigger-Zeit UTC-konsistent, project_summary liest Hauptchat, Privatkopie-Regel
- Trigger-Zeit (Issue 4): fires_at wird beim Anlegen IMMER auf UTC normalisiert
  (naiv = lokale Wanduhrzeit Europe/Berlin → UTC, aware → astimezone). Passt zur
  UTC-Ausfuehrung in background.py → feuert zur gemeinten Ortszeit, zeitzonen-
  portabel. Tool-Desc + Prompt: fires_at = lokale Wanduhrzeit ohne Zone schreiben,
  Server rechnet um. Behebt den 2h-Versatz.
- project_summary (Issue 2): liest jetzt auch den Hauptchat (name='Hauptchat')
  und die Beschreibung sagt ARIA, es fuer 'schau mal in Projekt X / Hauptchat
  rein' / 'hol dir die Infos aus ...' zu nutzen.
- Privatkopie (Issue 3): IDENTITY_ANCHOR-Regel — YouTube-Downloads fuer Stefans
  PRIVATgebrauch sind eine Privatkopie (§53 UrhG), nicht reflexartig ablehnen/
  moralisieren; Urteil bleibt fuer Weiterverbreitung/kommerziell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:12:02 +02:00
duffyduck 761217cb5a release: bump version to 0.2.1.6 2026-07-12 10:09:49 +02:00
duffyduckandClaude Opus 4.8 ff6d31acbd docs(readme): Pro-Projekt-Queue, Rueckfrage-Loop, Drafts + TTS-Abspiel-Queue
App-Features + Completed-Log aktualisiert; veraltetes Barge-In-Cancel-Verhalten
durch das neue Anstellen-statt-Abbrechen ersetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:07:18 +02:00
duffyduckandClaude Opus 4.8 2d7a5784c2 docs(changelog): 0.2.1.5 — Pro-Projekt-Queue mit Rueckfrage-Loop
Queue pro Projekt (anstellen statt abbrechen) + [[AWAIT]]-Rueckfrage-Loop,
Pro-Projekt-Textfeld-Entwuerfe, TTS-Abspiel-Queue (back-to-back sprechen
nacheinander), Voice bricht nicht mehr ab. App + Diagnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:05:08 +02:00
duffyduckandClaude Opus 4.8 fa871219ae feat(queue): TTS-Abspiel-Queue — back-to-back-Antworten sprechen nacheinander
Bisher war die serialisierte Sprachausgabe zweier fast gleichzeitig fertiger
Antworten Timing-Glueck: PcmStreamPlayer.start() ruft stopInternal() (flush+
release), eine neue Antwort haette die laufende also abgeschnitten, sobald ihr
Audio waehrend der Wiedergabe der ersten ankam.

Jetzt echte Abspiel-Queue im audioService: kommt eine neue HOERBARE Antwort
waehrend eine andere noch hoerbar spielt (pcmAudiblePlaying bis
PcmPlaybackFinished, nicht nur bis Stream-Ende), werden ihre PCM-Chunks
gepuffert und erst nach dem Drain der laufenden nachgespielt. Bei wartender
Antwort meldet PcmPlaybackFinished NICHT 'fertig' (kein Wake-Word-Re-Arm).
Harter Stop/Barge-In/Mute verwirft die Queue. Race gegen gleichzeitige Chunks
einer dritten Antwort geschlossen (Flags vor await gesetzt). onPcmCached meldet
den WAV-Pfad nachgespielter Antworten fuer Mund-Button-Replay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:03:31 +02:00
duffyduck e61a0ff871 release: bump version to 0.2.1.5 2026-07-12 09:44:34 +02:00
duffyduckandClaude Opus 4.8 c9a1d80696 feat(queue): Diagnostic spiegelt Pro-Kontext-Queue + Rueckfrage + Drafts
Diagnostic bekommt denselben Queue-Automaten wie die App: anstellen statt
abbrechen, awaiting_reply pausiert die Queue (naechste Eingabe beantwortet die
Rueckfrage), Queue-Banner mit loeschbaren Eintraegen ueber dem Eingabefeld,
und Pro-Kontext-Textfeld-Entwuerfe (Feldinhalt bleibt beim Kontextwechsel
erhalten). rvs_chat-Handler liest awaiting_reply aus der Bridge-Payload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:41:24 +02:00
duffyduckandClaude Opus 4.8 8a0670a3d2 feat(queue): Pro-Projekt-Nachrichten-Queue mit Rueckfrage-Loop + Textfeld-Entwuerfe (App)
Zweite Nachricht waehrend ARIA arbeitet wird jetzt ANGESTELLT statt den
laufenden Task abzubrechen. Stellt ARIA eine blockierende Rueckfrage, pausiert
die Queue und die naechste Eingabe beantwortet sie — bis eine finale Antwort
kommt, dann laeuft der naechste Queue-Eintrag (pro Projekt unabhaengig).

- Brain: ARIA deklariert Rueckfragen per unsichtbarem [[AWAIT]]-Marker
  (wie speak/converse; kein '?'-Raten). _extract_await_marker strippt ihn,
  chat() gibt 5-Tupel (+awaiting_reply), System-Prompt erklaert den Marker.
- Bridge: awaiting_reply aus Brain-Response in die chat-Broadcast-Payload.
- App: app-lokale Queue + Zustandsautomat (idle/running/awaiting_reply) pro
  Projekt; Send-Flow von Abbruch auf Anstellen; Stop-Button (cancelRequest)
  schaltet die Queue weiter; sichtbare pending_queue-Bubbles (tippen loescht);
  Rueckfrage-/Queue-Banner ueber dem Eingabefeld. Voice bricht nicht mehr ab
  (haltet nur TTS, serialisiert im Brain-Lock); Text-Send erkennt Brain-busy
  als Fallback. Pro-Projekt-Textfeld-Entwuerfe (Draft-Map + AsyncStorage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 09:35:46 +02:00
duffyduckandClaude Opus 4.8 2d9c42a7ea docs(changelog): 0.2.1.1–0.2.1.4 nachgetragen — der große Tag
Vier Versionen aufgeholt: manifest.speak (0.2.1.1); Standort-Intelligenz +
speak/converse-pro-Aufruf + Anti-Halluzination (0.2.1.2); skill_get + converse-
Fast-Path + Voice-Bubble-Fix (0.2.1.3); tool-loses local + Info-Halluzination-
Guard + leeres-<voice>-Fix + Live-Datei-Anhang + TDZ- & QRScanner-Cleanup und
ARIAs live geschärfter Spotify-/yt-dlp-Skill (0.2.1.4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:13:42 +02:00
duffyduck ff1205eb6b release: bump version to 0.2.1.4 2026-07-12 02:10:13 +02:00
duffyduckandClaude Opus 4.8 d12f67320b fix(app): QRScanner tsc-clean — toter Prop raus, kaputte camera-kit-Typen umgangen
colorForScannerFrame existiert in react-native-camera-kit v13 nicht (No-Op) —
entfernt. Die Lib markiert zudem etliche optionale CameraScreen-Props faelschlich
als required (defaultProps fuellen sie zur Laufzeit); Props lokal als any
gespreadet, um die fehlerhaften .d.ts zu umgehen ohne Runtime-Verhalten zu
aendern. scanBarcode/onReadCode bleibt die korrekte v13-Barcode-API.

Projekt jetzt komplett tsc-clean (0 Fehler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:08:35 +02:00
duffyduckandClaude Opus 4.8 24a1b4d837 fix(app): sendTextMessage nach seine deps verschoben — TDZ-Fehler weg
sendTextMessage stand vor interruptAriaIfBusy und sendPendingAttachments,
hatte beide aber im deps-Array → Temporal Dead Zone (TS2448/2454). Lief nur,
weil Babel const→var hebt (deps auf erstem Render undefined, Body laeuft erst
bei Interaktion). Deklaration hinter beide verschoben — Abhaengigkeit ist
einseitig, sendTextMessage wird nur in der JSX genutzt. tsc-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:52 +02:00
duffyduckandClaude Opus 4.8 db96258f7d fix(app): ARIA-Datei-Anhang erscheint live an der Nachricht, nicht erst nach Seitenwechsel
Die Live-chat-Payload trug keine files — Anhaenge kamen nur als separates
file_from_aria-Event (eigene, teils unsichtbare Bubble). An der Text-Nachricht
tauchte die Datei erst nach einem Seitenwechsel auf (Reload aus chat_backup,
das die files kennt).

- Bridge: files jetzt direkt in der chat-Payload (selbe Struktur wie Backup).
- App: chat-Handler haengt payload.files an die Text-Bubble (wie der Reload-Pfad)
  und entfernt die redundante Solo-file_from_aria-Bubble mit passendem serverPath.
- App: file_from_aria legt keine Doppel-Bubble an, wenn die Datei schon an einer
  Nachricht haengt (Event-Reihenfolge-unabhaengig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:01:37 +02:00
duffyduckandClaude Opus 4.8 becc83d9e3 fix(bridge): leeres <voice></voice> macht TTS nicht mehr stumm
Claude haengt manchmal reflexartig ein leeres <voice></voice> an (v.a. bei
Spotify-Antworten). clean_text_for_tts nahm dann group(1)='' → text='' → gar
keine Sprachausgabe. Das erklaerte, warum Playlist-Wechsel 'Fliegen' vorgelesen
wurde (Claude schrieb Inhalt ins Tag), 'Prodigy' aber stumm blieb (leeres Tag)
— reiner Claude-Output-Zufall, nicht Skill/Playlist-Name.

Fix: leeres/whitespace <voice> ignorieren und den normalen Anzeigetext lesen.
Deterministisch, unabhaengig von Claudes Tag-Reflex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:45:43 +02:00
duffyduckandClaude Opus 4.8 44220edbd1 revert(router): Live-Wortliste raus — Modell soll selbst eskalieren
_LIVE_HINTS war die falsche Idee: aus offenem Freitext per Wortliste die
Absicht raten ist nie vollstaendig, jeder Miss = Halo (nur von per-Skill auf
per-Wort verschoben). Generisch heisst nicht 'groessere Regex', sondern: das
Modell entscheidet selbst ob es Grundwahrheit/ein Tool braucht (<<ESCALATE>>,
Prompt). Der 8B kann das noch nicht zuverlaessig → local bleibt per Einstellung
abschaltbar (aus, nicht raus); ein staerkeres lokales Modell uebernimmt spaeter
die Selbst-Erkennung. Absicherung bleibt der Output-Guard, nicht der Input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:20:02 +02:00
duffyduckandClaude Opus 4.8 fc13957000 fix(router): 'lieder'/'songs'/'springe...weiter' matchen jetzt auch (Live-Gate)
'lied' hatte eine Wortgrenze → 'lieder' (lied+er) matchte nicht, 'springe
zwei lieder weiter' rutschte an local vorbei. lied\w*/song\w* + spring\w*
...weiter ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:06:40 +02:00
duffyduckandClaude Opus 4.8 60580fd67e refactor(brain): local wieder tool-los (B1a) — 8B nur noch Reden
B1b hatte dem 8B run_*/web_search-Tools gegeben. Das war die Wurzel von
BEIDEM: (1) ein 8B ist ein unzuverlaessiger Tool-Caller und erfindet lieber
eine plausible Antwort ('der Song ist X') statt das Tool zu rufen → halo;
(2) das zwang zu per-Skill-Guards (skaliert nicht).

Fix ohne eine einzige per-Skill-Zeile:
- Local: tools=[] — kann kein Skill-Ergebnis mehr faelschen.
- Router: _LIVE_HINTS schickt alles mit aktueller Grundwahrheit
  (Wetter/News/Uhrzeit/Musik/Preise/Timer/…) VORAB an Claude, nicht erst
  nach einem gescheiterten Local-Versuch (kein Doppel-Zahlen). Topic-Ebene,
  nicht per-Skill — ein neuer Skill braucht hier keine Zeile.
- Prompt: _AWARENESS sagt local explizit, dass es tool-los ist und fuer
  Live/Aktion eskaliert.

Arbeitsteilung: Fast-Path (Regex→Skill, deterministisch, 0 halo) fuer
Befehle · Local (tool-los) fuers Reden · Claude fuer alles mit Tool/Fakt/
Gedaechtnis/Urteil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:59:31 +02:00
duffyduckandClaude Opus 4.8 2d83e4ad8b fix(brain): Info-Halluzination-Guard + expliziter Claude-Wunsch im Router
Local erfand Live-Auskuenfte (aktueller Song, Restzeit, Skip-Titel) ohne
run_spotify zu rufen. Neuer Detektor _claims_live_media_state() + Guard
eskaliert solche Behauptungen ohne echten Skill-Call an Claude. Local-Prompt
gehaertet: nie Titel/Interpret/Restzeit ohne Tool-Ergebnis nennen.

Router: _FORCE_CLAUDE respektiert 'nimm Claude/Clodi' → nie lokal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:52:36 +02:00
duffyduck 629a3d82ac release: bump version to 0.2.1.3 2026-07-11 23:18:27 +02:00
duffyduckandClaude Opus 4.8 9bb90a777a fix(app): Sprachnachricht-Bubble verschwindet nach manuellem Stop nicht mehr
Ohne Ohr (manuelle Aufnahme) + Stop-Button: das echte STT-Endpoint (mit Text)
fuellt die Bubble, aber danach klappt ein zweites, LEERES stream_end-Endpoint
nach (Audio schon transkribiert). Der Empty-Handler loeschte die Bubble
bedingungslos per audioRequestId — auch die schon mit Text gefuellte.
Ergebnis: Bubble weg, obwohl ARIA an der Antwort arbeitet; erst nach
App-Neustart (aus chat_backup) wieder da.

Fix: leeres Endpoint entfernt nur noch den NOCH UNAUFGELOESTEN Platzhalter
(Text enthaelt 'Spracheingabe wird verarbeitet'), nicht eine bereits
aufgeloeste Bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:17:04 +02:00
duffyduckandClaude Opus 4.8 c3bb7985a2 fix(skills): skill_get-Tool nachgereicht — kein Blind-Rewrite mehr
Skandal-Ursache: die skill_update-Anleitung sagt "erst skill_get lesen, dann
updaten" — aber skill_get existierte GAR NICHT als Tool. ARIA/Claude konnte
einen Skill also nie LESEN vor dem Aendern → Blind-Rewrite aus Beschreibung +
Gedaechtnis. (Claude sagte korrekt "skill_get steht mir nicht zur Verfuegung"
und hat den Spotify-Skill trotzdem komplett neu geschrieben.)

Neu: skill_get(name) liefert Manifest (args/fast_patterns/speak/converse) +
kompletten entry_code (echter Python) + README. skills.read_skill_source().
So sieht ARIA den Ist-Code und aendert gezielt statt blind zu ueberschreiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:04:37 +02:00
duffyduckandClaude Opus 4.8 debd96b16f fix(fast-path): converse folgt dem Skill (Manifest/Output), nicht hart False
Fast-Path hatte converse hart auf False — ein Fast-Path-Skill konnte damit nie
ein Dialog-Skill sein. Jetzt konsistent zu local/Claude: speak UND converse
kommen aus dem Skill-Manifest-Default und werden vom Skill-JSON-Output pro
Aufruf ueberschrieben. Default (kein Flag) bleibt false/false = stumm/stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:57:52 +02:00
duffyduck 486d7dedbb release: bump version to 0.2.1.2 2026-07-11 22:48:43 +02:00
duffyduckandClaude Opus 4.8 d5bcbb4814 feat(skills): Skill steuert Ausgabe selbst — speak + converse, pro Aufruf
Verallgemeinert die Ausgabesteuerung, dynamisch statt hardcoded:
- Zwei Flags: speak (vorlesen?) + converse (danach 30s weiterlauschen?).
  Getrennt, weil 'vorlesen' und 'Dialog offen halten' verschiedene Dinge sind
  ('was laeuft gerade' → vorlesen JA, aber keine 30s).
- Statischer Manifest-Default (speak/converse) PLUS: der Skill kann beide im
  JSON-Output PRO AUFRUF setzen und den Default ueberschreiben — so kann EIN
  Skill gemischt sein (Spotify: 'next' stumm/stop, 'was laeuft' vorlesen/stop).
- chat() gibt jetzt (reply, answered_by, speak, converse) zurueck; gilt fuer
  Fast-Path (converse immer False), local UND Claude (run_*-Skill setzt beide).
- Brain: _last_skill_flags aus Skill-stdout-JSON, _skill_response_flags mergt
  Output > Manifest > False. main.py ChatOut.converse, background.py angepasst.
- Bridge: converse aus /chat gelesen + in Chat-Payload + _process_core_response.
- App: converseRef aus der Payload; onPlaybackFinished endet mit skipPassive
  wenn converse=false (vorlesen ohne 30s).
- Skill-Bau-Anleitung (skill_create/update-Schema): speak+converse dokumentiert
  MIT dem WARUM, damit ARIA sie beim Bauen sinnvoll setzt (nicht nur mechanisch).
- Prompt-Hardcode fuer Spotify-Faehigkeiten raus → Skills beschreiben sich selbst.

Bestehende Skills ohne Flags = false/false = stumm/stop (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:47:30 +02:00
duffyduckandClaude Opus 4.8 54ebd57990 fix: generischer Skill-Prompt (kein Hardcode) + Stop im 30s-Lauschen beendet
- router.py: die run_spotify-Faehigkeiten NICHT mehr im Prompt aufzaehlen
  (war selbst Hardcoding). Generisch: "run_*-Skills — was sie koennen steht in
  IHRER Tool-Beschreibung, lies + nutze sie fuer alles Passende". Skills
  beschreiben sich selbst (dynamisch, ARIA-authored). Anti-Halluzination bleibt.
- App: Stop-Button waehrend passivem 30s-Lauschen ('listening') beendet jetzt
  sauber (exitPassiveListening) statt via leerem Endpoint den Passiv-Stream neu
  zu starten — die 30s liefen sonst von vorne los.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:36:01 +02:00
duffyduckandClaude Opus 4.8 fb06ed928c fix(local-llm): Anti-Halluzination — behauptete Skill-Aktion ohne Tool → Claude
Local hat "Spotify: überspringe 3 Titel" / "Playlist X abspielen" geantwortet
OHNE run_spotify je zu rufen (Skill-Logs: kein Aufruf) — reine Fabrikation,
real passierte nichts. Zwei Ursachen behoben:
- Prompt: run_spotify-Beschreibung von "next/pause/weiter" auf JEDE
  Musiksteuerung erweitert (Playlist, Gerät, überspringen mehrerer, Lautstärke)
  + harte Anti-Halluzinations-Regel (nie eine Aktion behaupten ohne Tool-Call).
- Guard: sagt local eine Steuerbefehl-Quittung ('Spotify: …', 'Playlist …
  abspielen', 'überspringe … Titel') aber hat KEINEN run_*-Skill gerufen
  (_local_ran_skill=False) → eskalieren, Claude ruft das Tool zuverlässig.

_looks_like_skill_action bewusst eng (Doppelpunkt-Praefix / spezifische Verben),
trifft normale Musik-Konversation nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:30:00 +02:00
duffyduckandClaude Opus 4.8 08bb257791 fix(bridge): Ortsname rechtzeitig da — Geocode vor Präfix-Bau awaiten
Der Reverse-Geocode lief nur async im Hintergrund — die ERSTE Nachricht an
einem Ort (v.a. direkt nach Bridge-Neustart, Cache leer) wurde gebaut BEVOR
der Name fertig war → Präfix ohne Ort → local suchte Wetter mit rohen
Koordinaten und fand nichts.

Neu: _ensure_place_name() awaitet den (laufenden oder frisch gestarteten)
Geocode kurz mit Timeout, bevor der Standort-Präfix gebaut wird. Gecacht →
nur die erste Nachricht an einem neuen Ort zahlt ~0,5s, danach instant.
Timeout/Fehler → Fallback auf reine Koordinaten (kein Hänger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:18:42 +02:00
duffyduckandClaude Opus 4.8 44982a9b3c feat(bridge): exakte Peilung (Grad) zusätzlich zur Himmelsrichtung im Präfix
Neben "nordwestwaerts" jetzt auch die genaue Bearing-Gradzahl (0-359) —
"unterwegs nordwestwaerts (304 Grad)". Die KI nutzt, was die Frage braucht
(Pannenhilfe → Ort/km, Luftfahrt/Navigation → exakte Peilung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:12:38 +02:00
duffyduckandClaude Opus 4.8 ddb1bfac6a feat(bridge): Fahrtrichtung (Himmelsrichtung) im Standort-Präfix
Aus dem permanenten GPS wird die Bewegungsrichtung berechnet: Bearing zwischen
einem Anker-Punkt und der aktuellen Position, gemappt auf 8-Wind-Adverb
(nordwestwaerts …). Der Anker rueckt erst bei echter Bewegung (>25 m) nach,
sonst zaehlt GPS-Jitter im Stand nicht — Stehenbleiben behaelt die letzte
Richtung, Umdrehen liefert automatisch die neue.

Praefix jetzt z.B.:
  [Stefans aktueller Standort: A 28 bei km 55,6, Oldenburg, Niedersachsen
   (GPS 53.12, 8.22), unterwegs nordwestwaerts. ...]

Reiner Bridge-Fix (Haversine + Bearing lokal, kein Dienst noetig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:11:07 +02:00
duffyduckandClaude Opus 4.8 4605698b02 feat(bridge): Standort-Präfix mit Straße+Hausnr, Straßen-Ref + km (Autobahn)
Erweitert den Reverse-Geocode: Nominatim zoom=18 → Straße + Hausnummer, PLZ,
Stadt, Bundesland ("Am Wunderburgpark 6, 26135 Oldenburg, Niedersachsen").
Bei Autobahn/Bundes-/Kreisstraße wird die Ref (A 28 / B 75 / K …, aus
extratags) vorangestellt + best-effort Kilometer via Overpass-Milestone
(highway=milestone, distance-Tag, naechster im Umkreis) → z.B.
"A 28 bei km 55,6, Oldenburg, Niedersachsen". Für Pannenhilfe & Co.

Cache-Raster von ~1km auf ~110m verfeinert (Straßen-Genauigkeit). Overpass
nur wenn's nach Straße/Autobahn aussieht (ref/Autobahn im Namen). Alles
best-effort mit stiller Fallback-Kette (Fehler → gröberer Ort → nur GPS).
NOMINATIM_URL + OVERPASS_URL env → self-hostbar.

Richtung ("Richtung Leer") bewusst weggelassen — nicht zuverlaessig aus
einem Punkt ableitbar (braucht Fahrtrichtung/Routing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:06:15 +02:00
duffyduckandClaude Opus 4.8 1c11cb6a2f feat(bridge): GPS→Ortsname im Standort-Präfix (Reverse-Geocode, keine Tokens)
Das lokale 8B kann Koordinaten nicht zuverlässig in eine Stadt uebersetzen
(riet "Berlin" fuer Oldenburg). Claude kann's, aber das kostet Tokens.
Loesung: die Bridge loest lat/lon EINMAL via Nominatim (OpenStreetMap, gratis,
keyless) auf und schreibt den Namen in den Praefix:
  [Stefans aktueller Standort: Oldenburg, Niedersachsen (GPS 53.12, 8.22). ...]
So muss KEIN Modell mehr raten — local UND Claude lesen den Ort direkt ab,
Token-Ersparnis bleibt voll erhalten.

- _persist_location merkt sich den letzten Ort + triggert den Geocode async
  (im Hintergrund, gecacht pro ~1km-Raster, nur bei Bewegung → Nominatim-
  Rate-Limit locker eingehalten).
- _build_core_text nutzt frische ODER letzte bekannte Position (Praefix faellt
  beim passiven Weiterreden ohne frisches GPS nicht mehr weg) + den Ortsnamen.
- NOMINATIM_URL env (Default oeffentlicher Dienst) → spaeter self-hostbar.
Reiner Bridge-Fix, kein APK/Brain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:02:47 +02:00
duffyduck 6cf75644d8 release: bump version to 0.2.1.1 2026-07-11 21:46:56 +02:00
duffyduckandClaude Opus 4.8 2c1de6705b feat(skills): speak-Flag gilt jetzt auch im Claude-Pfad
Vorher gab Claude immer speak=true zurück — führte Claude einen speak=false-
Steuerbefehl aus (z.B. "spiele auf duffy desktop weiter"), wurde die Antwort
trotzdem vorgelesen + 30s. Jetzt konsistent zu Fast-Path/local: führt Claude
einen run_*-Skill mit speak=false erfolgreich aus → speak=false (kein TTS,
App STOP). Der Text landet aber normal in der Bubble (Stefans Wunsch: Text ok,
nur nicht vorlesen). Antwort-Skills (speak=true) + reine Konversation bleiben
gesprochen; bei Skill-Fehler bleibt's gesprochen (Erklaerung soll man hoeren).

Reiner Brain-Fix — App liest speak bereits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:45:27 +02:00
duffyduckandClaude Opus 4.8 2716bc62ff feat(skills): Skill entscheidet selbst ob vorgelesen wird (manifest.speak)
Verallgemeinert das "run_* → stumm"-Heuristik: jeder Skill deklariert im
Manifest ein speak-Flag.
- speak=false (Default) = Steuerbefehl (Spotify, Licht) → kein TTS, App
  beendet direkt (STOP), wie bisher.
- speak=true = Antwort-Skill (Info/Ergebnis) → Antwort wird vorgelesen +
  Gespraechs-Fenster bleibt offen.

Gilt für BEIDE Pfade: Fast-Path (_fast_path_speak aus skill.speak) und
lokale Skill-Ausführung (_local_turn_speak = _skill_speak_flag(run_*)).
Info-Tools (web_search/memory_search/trigger_timer) bleiben gesprochen.

- skills.py: speak in create_skill + update_skill-allowed.
- agent.py: skill_create/skill_update Tool-Schema dokumentiert speak (damit
  ARIA es beim Skill-Bau setzen kann), Dispatch reicht es durch.
- App: _isSilent nur noch speak===false (kein answeredBy=fast-path-Fallback
  mehr, sonst waere ein speak=true-Fast-Path faelschlich stumm).

Bestehende Skills ohne Feld = false = stumm (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:39:55 +02:00
duffyduckandClaude Opus 4.8 a9e1a46a2f feat(local-llm): Skill-Ausführung via local = kein TTS (speak=False)
Wenn das lokale LLM einen echten Skill (run_*, z.B. Spotify) ausführt, ist es
ein Steuerbefehl — genau wie Fast-Path. Jetzt speak=False: kein Vorlesen, und
die App beendet direkt (STOP) statt ins 30s-Gespräch zu gehen. Deckt den Fall
ab, wo Whisper sich verhört ("Nächster Slick") und local statt Fast-Path
den Skill auffängt.

Info-Tools (web_search/memory_search/trigger_timer) zählen NICHT — deren
Antwort/Bestätigung bleibt gesprochen. Reiner Brain-Fix: die App liest speak
bereits (2b48e5c), kein neuer APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:33:17 +02:00
duffyduck 245dfc4d73 release: bump version to 0.2.1.0 2026-07-11 21:10:02 +02:00
duffyduckandClaude Opus 4.8 6a94b574f2 fix(app): Mund-Button stoppt laufendes Vorlesen wirklich (Cache bleibt)
Zwei Fehler: (1) War der PCM-Stream schon komplett empfangen (isFinal durch),
sind pcmStreamActive+isPlaying false — der AudioTrack spielt aber seinen
Buffer noch sekundenlang aus. stopPlayback() returnte dann früh ("nichts
aktiv") und rief PcmStreamPlayer.stop() NIE → Mund-Button wirkungslos.
(2) stopPlayback() wirft pcmBuffer weg → die Cache-WAV waere unvollstaendig,
Nachhoeren via Lautsprecher-Symbol kaputt.

Neue _silenceAudibleOutput(): stoppt den AudioTrack IMMER (auch im Drain-Fall),
laesst aber pcmBuffer/pcmMessageId/pcmStreamActive stehen — restliche Chunks
cachen stumm weiter, isFinal schreibt die vollstaendige WAV. setMuted(true)
nutzt das statt stopPlayback(). stopPlayback bleibt fuer Barge-In/Cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:07:41 +02:00
duffyduckandClaude Opus 4.8 a5e2256a44 feat(app): Wake-Word-Empfindlichkeit + Weiterreden-Fenster in Settings
Fehlauslösung im Auto: Spotify läuft über die Lautsprecher, das Mikro hört
mit, openWakeWord halluziniert "computer" rein (Log: wake.detect state=armed
→ leerer Transkript). Der Echo-Canceler kann nur ARIAs eigenes TTS
rausrechnen, nicht Spotify (fremde App, kein Referenzsignal).

- Wake-Word-Threshold jetzt konfigurierbar (loadWakeThreshold), Default von
  0.5 auf 0.6 hoch (strenger → weniger Fehlauslösung). Slider im Wake-Word-
  Settings-Bereich (0.30–0.90, greift beim "Speichern + Aktivieren").
- Weiterreden-Fenster (passives Lauschen) als Slider, Default 30s (10–60s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:01:48 +02:00
duffyduckandClaude Opus 4.8 9fb29aa517 fix(voice): klarer Befehl = STOP, Gespraech = 30s passiv (kein zweiter Gong)
Nach dem Re-Arm-Fix rief die App resume() → das spielte einen zweiten Gong
und oeffnete ein neues Aufnahme-Fenster, obwohl Stefan nur einen Steuerbefehl
gab. Sein gewuenschtes Verhalten:
- Klarer Befehl (Fast-Path, speak=false, z.B. Liedersteuerung) = KEINE
  Konversation → STOP: direkt zurueck aufs Wake-Word (kein Gong, keine
  Aufnahme, kein 30s-Fenster).
- Gespraech (gesprochene Antwort) = kein neuer Gong, direkt 30s passives
  Lauschen (weiterreden wie mit einem Menschen), 30s still → Wake-Word.

endConversation(skipPassive) neu: true springt direkt zu armed statt in
passives Lauschen. onPlaybackFinished (Gespraech) → passiv (Vordergrund) bzw.
direkt armed (Hintergrund); stille Fast-Path-Antwort → skipPassive; manueller
Stop-Button → skipPassive. resume() bleibt nur noch Mic-Fail-Retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:52:04 +02:00
duffyduck a0494e90ee release: bump version to 0.2.0.9 2026-07-11 20:42:25 +02:00
duffyduckandClaude Opus 4.8 2b48e5cac6 fix(voice): Ohr re-armt nach Fast-Path-Befehl (kein TTS → kein Re-Arm-Trigger)
Fast-Path-Antworten sind speak=False → kein TTS → onPlaybackFinished feuert
nie. Das Wake-Word-Re-Arm haengt aber genau daran → nach "nächster Titel"
blieb das Ohr grau in 'conversing' stecken (Stefan im Auto, 2 Min gewartet,
kein Re-Arm). Log bestaetigt: nach stream.final kam kein wake.end/wake.start.

- Bridge: chat-Payload traegt jetzt 'speak' (war nur answeredBy).
- App: bei stiller Antwort (speak=false ODER answeredBy=fast-path) und
  laufender Konversation dieselbe Re-Arm-Logik wie bei TTS-Ende anstossen
  (aktiv → resume/Konversationsfenster, sonst → endConversation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:40:57 +02:00
duffyduck aefdff89dc release: bump version to 0.2.0.8 2026-07-11 20:33:12 +02:00
duffyduckandClaude Opus 4.8 f99e90f524 fix(voice): Doppel-Ausfuehrung — identischen STT-Endpoint-Text <5s deduppen
Im Auto cancelt der App-seitige No-Speech-Watchdog den conversing-Stream
vorzeitig (Whisper-Partials kommen bei Fahrgeraeusch verspaetet) und startet
passives Lauschen, waehrend die Bridge dieselbe Aeusserung parallel noch
finalisiert. Ergebnis: derselbe Befehl ("nächstes lied") wird zweimal
ausgefuehrt — aus zwei verschiedenen audioRequestIds, darum griff die
bestehende ID-Idempotenz nicht.

Text-Fenster-Dedup: identischer normalisierter Endpoint-Text innerhalb 5s
wird verworfen. Serverseitig, kein APK noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:30:37 +02:00
duffyduckandClaude Opus 4.8 1dd47888a8 fix(app): STT-Endpoint großzügiger + Mikro vor Re-Arm freigeben
#2 Vorzeitiges Absenden: endpointMs war hart 1500ms — im Auto (Sprechpausen)
schnitt das mitten im Satz ab (die 11.8s-Frage wurde bei "…ohne dass ein"
gekappt). Jetzt konfigurierbar (aria_stt_endpoint_ms, Default 2400, 1000-4000).

#3 Ohr bleibt ausgegraut: beim Re-Arm rief der Wake-Word-Service
OpenWakeWord.start(), waehrend die passive Streaming-Aufnahme noch das Mikro
hielt → start() schlug fehl → state=off. Neuer micReleaseHook cancelt die
Aufnahme VOR start() (endConversation / exitPassiveListening /
discardIfFreshlyTriggered). ChatScreen registriert den Hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:29:04 +02:00
duffyduckandClaude Opus 4.8 8b22557ca5 fix(fast-path): Voice-Befehle matchen wieder (GPS-Praefix, Umlaute, Kommas)
Voice-Nachrichten tragen den GPS-Hint-Praefix der Bridge
("[Stefans aktuelle GPS-Position: ...] nächstes lied bitte") und echte
Umlaute + Whisper-Kommas. Die ^...$-verankerten fast_patterns matchten damit
NIE — jeder Voice-Spotify-Befehl ging unnoetig an local/Claude (Tokens +
Latenz + TTS statt 0-Token-Fast-Path, was das Doppel-Ausfuehren mit anheizte).

_normalize_for_fast_match: fuehrende [ ... ]-Hint-Bloecke strippen, Umlaute
falten (ä→ae …), interne Satzzeichen (Komma/Punkt) raus. Pattern wird gleich
gefaltet. Router strippt den Praefix ebenfalls (Laengen-/Hint-Heuristik).

Wirkung: "nächstes lied bitte", "play", "nächster titel" -> Fast-Path
(instant, speak=False). "spiele auf duffy desktop weiter" bleibt Router-Sache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:25:26 +02:00
duffyduckandClaude Opus 4.8 bd78f384a0 fix(diagnostic): Verstecken im Kontext-Streifen (Main-Tab) statt nur Settings
Das Auge/Toggle war faelschlich nur in der vertikalen Projektliste unter
Einstellungen — Stefan managed Projekte aber ueber die Ordner-Bubbles im
Kontext-Streifen auf dem Main-Tab, und der filterte hidden gar nicht
(verstecktes Projekt blieb sichtbar, kein Auge).

renderContextStrip: versteckte Projekte standardmaessig raus, 🙈/👁-Auge pro
Bubble (eigenes onclick + stopPropagation, wechselt nicht den Kontext),
"versteckte anzeigen (N)"-Toggle-Chip am Ende; setStripProjectHidden patcht
+ raeumt Focus wenn noetig. project_changed refresht jetzt Streifen UND Liste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:18:07 +02:00
duffyduckandClaude Opus 4.8 14bdd4dbf4 fix(diagnostic): no-store fuer index.html — Browser servierte alte Version
Die /-Response hatte keinen Cache-Control-Header. Der Browser cachte das
grosse Single-HTML-Dashboard heuristisch und servierte trotz (soft) Reload
die alte Inline-JS/CSS — neue Features (Projekte-verstecken-Button, Auge,
Token-Ersparnis-Card) tauchten erst nach Hard-Reload auf. Jetzt no-store +
no-cache, so dass jeder Load die frische Version bekommt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:15:34 +02:00
duffyduck bbfa1f73f6 release: bump version to 0.2.0.7 2026-07-11 17:14:32 +02:00
duffyduckandClaude Opus 4.8 fed3cb9f18 fix(projects): Live-Sync verstecken zwischen App und Diagnostic (ohne Refresh)
Kern-Bug: der Diagnostic-Server reichte ein von RVS empfangenes
project_changed NIE an die Browser weiter — der Handler in index.html war
toter Code, der Diagnostic aktualisierte die Projektliste also nie live.

- server.js: RVS-Handler forwardet project_changed jetzt an die Browser-Tabs;
  der /api/brain-Proxy broadcastet project_changed (RVS + lokal) nach jeder
  erfolgreichen Projekt-Mutation (create/switch/end/archive/PATCH inkl. hidden).
- App ProjectsBrowser: sendet project_changed nach dem Verstecken/Sichtbar-
  machen und laedt bei eingehendem project_changed selbst neu.

Damit: verstecken in der App -> sofort im Diagnostic weg (und umgekehrt),
ohne Seiten-Refresh. Diagnostic-Hide funktionierte schon (Daten/Proxy/Logik
korrekt) — versteckte sind per "Versteckte anzeigen (N)"-Toggle sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:11:51 +02:00
duffyduckandClaude Opus 4.8 9ec9119bd9 docs(changelog): 0.2.0.6 abgebunden (Unreleased → [0.2.0.6])
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:51:10 +02:00
duffyduck 06d0064c8c release: bump version to 0.2.0.6 2026-07-11 16:50:22 +02:00
duffyduckandClaude Opus 4.8 a7cde52ee6 docs(changelog): Plan-B-Epos (0.2.0.4/5) + heutige Fixes nachgetragen
Changelog endete bei 0.2.0.3 (07-10). Nachgetragen:
- 0.2.0.4/5 (07-11): Plan B Lokales LLM (Router, Fast-Lane, SearXNG-Tools,
  llama-swap/B0.5, Quell-Badge, speak-Flag, --system-prompt Identity-Fix,
  Diskretions-Regel).
- Unreleased (07-11): Spotify-Resume via MEDIA_PLAY, TTS-Zahlen ausschreiben,
  "ARIA denkt"-Sync, weiche Tool-Fehler-Eskalation, Token-Ersparnis-Metrik,
  Projekte verstecken (Diagnostic + App), von ARIA geschaerfter Spotify-Skill.
Alt-Label "Unreleased 07-10" korrekt auf [0.2.0.3] gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:49:02 +02:00
duffyduckandClaude Opus 4.8 a353b62b37 feat(app): Projekte verstecken auch in der App (Auge + Toggle)
Spiegelt das Diagnostic-Feature auf App-Seite:
- Project-Interface + updateProject um hidden erweitert; setProjectHidden().
- ProjectsBrowser: versteckte Projekte standardmaessig ausgeblendet (mama
  sieht sie nicht). Auge pro Zeile (🙈 verstecken / 👁 sichtbar), Toggle
  "👁 Versteckte anzeigen (N)" blendet sie temporaer gedimmt + Badge ein
  zum Ansehen/Auswaehlen/Wieder-Sichtbarmachen. Eigener Touch am Auge, damit
  der Tap nicht das Projekt wechselt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:45:15 +02:00
duffyduckandClaude Opus 4.8 bc47c2053b feat(projects): Projekte verstecken (Auge-Toggle im Diagnostic)
Neues hidden-Flag pro Projekt (default false, bleibt voll nutzbar — nur
optisch aus der Liste ausgeblendet, unabhaengig von status/archived).
- projects.py: hidden in create_project + update_project-Patchkeys.
- main.py: ProjectUpdateBody.hidden → PATCH /projects/{id} setzt es.
- Diagnostic: pro Projekt-Bubble ein Auge (🙈 verstecken / 👁 sichtbar
  machen); Header-Toggle "Versteckte anzeigen (N)" blendet sie temporaer
  ein (gedimmt + Badge) zum Ansehen/Auswaehlen, ohne sie permanent
  sichtbar zu machen. Auge im eingeblendeten Zustand macht sie dauerhaft
  wieder sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:41:56 +02:00
duffyduckandClaude Opus 4.8 dc043ceb4d feat(metrics): lokaler LLM-Verbrauch + Claude-Ersparnis in Diagnostic
metrics.jsonl-Eintraege tragen jetzt 'source' (claude|local|fast-path).
- log_local_call(): echte usage-Tokens vom Adapter (prompt/completion),
  sonst chars/4-Schaetzung. Geloggt pro Tool-Runde im lokalen Fast-Lane.
- log_fast_path(): reiner Skill, 0 Prompt-Tokens — gesparter Claude-Call.
- aggregate() liefert zusaetzlich by_source (calls/tokens_in/tokens_out).
  Alt-Eintraege ohne source zaehlen als claude (rueckwaerts-kompatibel).

Diagnostic Gehirn-Tab: neue Card "Lokales LLM & Claude-Ersparnis" — pro
Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) und
lokale Token-Last (eigene HW, kein Quota) + Info-Block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:39:02 +02:00
duffyduckandClaude Opus 4.8 8bac7c56bc fix(local-llm): weiche Tool-Fehler eskalieren (Exit 0 + Fehlertext)
Die Escalate-on-Tool-Error-Logik griff nur bei hartem FEHLER-Prefix (Exit
!= 0). Action-Skills melden Fehlschlaege aber oft im stdout-Text bei Exit 0
(Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Das lokale
LLM las den Fehler dann brav vor statt zu eskalieren — und wiederholte beim
Nachhaken dieselbe leere Absichtserklaerung.

Generisch (nicht Spotify-spezifisch): fuer run_*-Skills werten weiche
Fehler-Marker im Ausgabetext ebenfalls als Fehlschlag → Claude uebernimmt
das mehrstufige Mitdenken. Info-Tools (web_search/memory_search) ausgenommen,
damit deren Inhalt das Wort "Fehler" tragen darf. Eskalieren ist immer sicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:25:52 +02:00
duffyduckandClaude Opus 4.8 3b6d36f2de fix(sync): "ARIA denkt" bleibt haengen obwohl Turn fertig
Zwei Ursachen, beide adressiert:
- App raeumte den (kontext-scoped) Thinking-Indikator nur ueber das
  agent_activity 'idle' der Bridge. Kam das nicht an (dedup/mismatch),
  blieb "ARIA denkt" + Abbrechen stehen. Jetzt raeumt die App den
  Indikator beim Eintreffen der Antwort selbst (definitiver Turn-Ende-Beweis).
- Bridge sendete 'thinking' mit der REQUEST-projectId, 'idle' aber mit der
  TURN-projectId (Brain kann umrouten, z.B. Voice-Sticky). Bei Abweichung
  bekam der Request-Kontext nie sein idle → zusaetzliches idle fuer die
  Request-projectId am Turn-Ende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:41 +02:00
duffyduckandClaude Opus 4.8 7f6e266d15 fix(tts): freistehende Ganzzahlen tag-unabhaengig ausschreiben
Regression seit das lokale LLM leichte Turns (Wetter, Spotify) beantwortet:
es nutzt bewusst KEINE <voice>-Tags, an denen frueher die Zahl-Aussprache hing.
clean_text_for_tts schrieb nur Uhrzeiten/Dezimalzahlen/Zahl+Einheit aus, nie
freistehende Ganzzahlen ('23°C', '100%', '7 Nachrichten').

Neuer vollstaendiger Konverter _int_to_words_de (0..999999) + generischer
Durchlauf am Ende von clean_text_for_tts, der alle uebrigen Ganzzahlen zu
Woertern macht. Tag-unabhaengig -> gilt fuer local UND claude. Lange
Ziffernfolgen (IDs/Codes, >6 Stellen) bleiben Ziffern; an .,:/ klebende
Zahlen (IPs, Versionen) werden uebersprungen. Nebenbei: fehlendes Leerzeichen
bei '23°C' -> 'dreiundzwanzig Grad Celsius' gefixt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:18:58 +02:00
duffyduckandClaude Opus 4.8 4d1664a328 fix(app): zuverlaessiger Spotify-Resume via MEDIA_PLAY-KeyEvent statt Focus-Nudge
Native AudioFocus: dispatchMediaPlay() (echter KEYCODE_MEDIA_PLAY an die aktive
MediaSession, wie die Kopfhoerer-Play-Taste) + isMusicActive() zum Gaten.
audio.ts merkt vor dem Focus-Grab ob Musik lief und resumt am Dialog-Ende nur
dann — deterministisch statt des auf OnePlus flakigen nudgeMediaResume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:13:18 +02:00
duffyduck 1ff02f9763 release: bump version to 0.2.0.5 2026-07-11 16:11:12 +02:00
duffyduck 5b5d61513f fix(router): lokales Tier eskaliert Gedaechtnis-/Personen-Fragen an Claude
Test 'Wer ist Melanie?': das lokale Qwen klammte zu + verwechselte den Frager
('frag Stefan direkt' — Stefan IST der User), weil der schlanke Lokal-Prompt kein
Memory hat. Claude dagegen antwortete diskret + korrekt (und haengte sogar selbst
<voice></voice> an → stumm, 'jemand koennte mithoeren').

Fix: Local-Prompt weist an, bei Fragen zu Stefans Leben/Personen/Beziehungen/
Vergangenheit/gespeichertem Wissen NICHT aus dem Nichts zu antworten, sondern
zu eskalieren (<<ESCALATE>> → Claude mit vollem Gedaechtnis). Die Diskretions-
Regel selbst bleibt (funktioniert auf Claude einwandfrei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)
2026-07-11 15:51:18 +02:00
duffyduckandClaude Opus 4.8 078ed17b57 fix(privacy): harte Diskretions-Regel — intime Details NIE ungefragt preisgeben
Schwerwiegend: ARIA hat bei Selbstvorstellung / "was weisst du ueber mich"
intime Details (Beziehungen, Partnerinnen, Lebensweise) proaktiv rausgedumpt —
ein Vertrauensbruch, wenn jemand mithoert. ARIA DARF das wissen, aber niemals
ungefragt aussprechen.

Regel in IDENTITY_ANCHOR (steht als einziger garantiert in BEIDEN Tiers, lokal
+ Claude): private/intime Infos sind hochvertraulich; nie von sich aus, nicht in
Vorstellungen/Zusammenfassungen/Triggern; nur auf konkrete Nachfrage, knapp und
diskret. Auf "was weisst du ueber mich": allgemein + diskret antworten, kein
Aufzaehlen. "Jemand koennte mithoeren" als Leitplanke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:38:52 +02:00
duffyduckandClaude Opus 4.8 0a2e59d756 feat(tts): System-Flag speak (ja/nein) pro Antwort statt <voice>-Tag-Hack
Stefans Idee: die QUELLE entscheidet ueber Vorlesen, nicht der Reply-Inhalt.
Fast-Path (reiner Steuerbefehl) = speak=False (stumm); ARIA-Antworten
(local/claude) = speak=True (vorlesen ok — eine Ansage ist sogar nett).

- agent.chat() gibt jetzt (reply, answered_by, speak) zurueck; main.py
  ChatOut.speak; background.py-Aufrufer angepasst.
- bridge: liest speak aus /chat, reicht es an _process_core_response; bei
  speak=False wird TTS uebersprungen — VOR jeder <voice>-Logik.

Robust: unabhaengig davon, ob die Skill-fast_pattern-Reply ein <voice></voice>
enthaelt (das verlor ARIA beim semantischen Skill-Rebuild — genau der Bug).
App braucht keinen Change: kein xtts_request -> kein Audio -> stumm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:55:12 +02:00
duffyduckandClaude Opus 4.8 2dfe6fd9c3 feat(local-llm): B0.5-2 — Live-Lade-Status des lokalen Modells in Diagnostic
Adapter meldet bei Modellwechsel/Erst-Load service_status (service=llm):
loading -> ready (mit loadSeconds; freshlyDownloaded 🎉 bei langem Erst-Load)
oder error. Laeuft ueber den vorhandenen Pfad: Adapter -> RVS -> Diagnostic
(RVS-Client) -> Browser -> updateServiceStatus (generisch; nur Label 'Lokales
LLM' ergaenzt). Kein Bridge-/server.js-Change noetig.

Download-% gibt llama-swap nicht her — daher Zustands-Status (laedt/bereit/
Fehler), im gemeinsamen Service-Banner wie whisper/flux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:29:18 +02:00
duffyduckandClaude Opus 4.8 8ae20a9bd8 feat(local-llm): B0.5 — llama-swap + lokale Modellauswahl in Diagnostic
Mehrere lokale Modelle, on-demand geladen/geswappt, in Diagnostic waehlbar.
Design: das Brain schickt den Modellnamen (aus local_llm.json) im llm_request
mit -> Adapter -> llama-swap laedt/swappt. Keine separate Gamebox-Config noetig.

- xtts: `llama`-Container -> `llama-swap` (unified-cuda), config.yaml mit
  qwen3-8b (Standard) + qwen3-4b; Auto-Download via -hf, Cache /models geteilt
  (qwen3-8b schon da). Adapter -> llama-swap:8080, Timeout 600s (Erst-Download).
- adapter: `model` aus dem Request an llama-swap durchreichen (Fallback env).
- brain: router.load_config liest localLlmModel; local_llm_chat(model=...);
  agent gibt cfg-Modell mit; bridge reicht model durch (_local_llm + Route).
- diagnostic: /api/local-models-list (aus /shared/config/local_models.json,
  seeded), local-llm-config um localLlmModel erweitert; Dropdown "Lokales
  Modell" im Settings-Block + Erst-Download-Hinweis.

BLIND gebaut (Gamebox nicht testbar hier): llama-swap CLI/Config-Pfad beim
ersten Start via `docker logs aria-llama-swap` pruefen. Live-Lade-Status
(Adapter->Diagnostic) ist B0.5-2 (Folgeschritt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:11:59 +02:00
duffyduck 3ddcf665f0 release: bump version to 0.2.0.4 2026-07-11 13:48:14 +02:00
duffyduckandClaude Opus 4.8 7ae61701ad feat(app): optionaler Quell-Badge an ARIA-Bubbles (Einstellung, pro Geraet, default aus)
Zeigt wie in Diagnostic, ob eine Antwort vom lokalen Modell / Claude / Fast-Path
kam — aber in der App als Opt-in, damit die App "Mama-tauglich" bleibt.

- ChatScreen: ChatMessage.answeredBy; aus dem chat-Payload UND der History-Sync
  eingefangen; kleiner farbiger Badge im statusRow der ARIA-Bubble, nur wenn
  showSource an. State aus AsyncStorage aria_show_source (default false, per
  2s-Reload synchron mit den Settings).
- SettingsScreen: Toggle "Antwort-Quelle anzeigen" in der Chat-Bubbles-Card,
  schreibt aria_show_source (pro Geraet — Stefan an, Mama aus).
- Bridge liefert answeredBy schon in chat_backup/History mit (kein Change noetig).

Server-Teil in 9cc1aec. APK-Rebuild noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:46:12 +02:00
duffyduckandClaude Opus 4.8 9cc1aec5ec feat(diagnostic): Quell-Badge an ARIA-Bubbles (local / claude / fast-path)
Zeigt pro Antwort, welcher Backend sie erzeugt hat — farbcodiert (lokal=gruen,
Claude=blau, Fast-Path=lila). Nur in Diagnostic (App bleibt Mama-tauglich).

- agent.chat() gibt jetzt (reply, answered_by) zurueck; an jedem Return-Punkt
  gesetzt (fast-path/local/claude). Beide Aufrufer (main.py, background.py)
  angepasst. main.py: ChatOut.answered_by.
- bridge: liest answered_by aus /chat, reicht es an _process_core_response,
  broadcastet es im chat-Payload UND persistiert es in chat_backup (answeredBy)
  → bleibt nach Reload.
- diagnostic: srcBadgeHtml() rendert den Badge in Live-Chat + History;
  server.js liefert answeredBy in der chat_history.

Erweiterbar: spaeter kann ein Bild-Backend (FLUX/Modellname) denselben Kanal
nutzen. Modellwechsel-fuer-Antworten (groessere Modelle bei mehr GPUs) bleibt
B0.5/Skalierungs-Thema im Plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:40:36 +02:00
duffyduckandClaude Opus 4.8 53c098a9c8 refactor(local-llm): generische Tool-Fehler-Eskalation statt per-Skill-Router-Code
Stefan-Punkt: der 'auf <Geraet>'-Router-Patch war ein per-Fall-Band-Aid, das
nicht skaliert (naechster Skill mit komplexer API → wieder patchen). Besser:
das LLM merkt es selbst.

- Router-Hardcoding zurueckgenommen (kein 'auf Geraet'→Claude mehr).
- GENERAL: im lokalen Tool-Loop → scheitert ein Tool-Call (Ergebnis beginnt mit
  'FEHLER'), uebernimmt Claude. Gilt fuer JEDEN Skill, kein per-Fall-Wissen im
  Router. Lokal probiert, bei Fehler eskaliert.
- skill_create-Anleitung: SEMANTISCH bauen — Skills bieten klare Operationen als
  args (action/device_name), NICHT rohe {path,method,body}-Durchreichung. Das
  args-Schema ist die 'Bedienungsanleitung', die das LLM sieht (die haben wir
  also schon — sie muss nur semantisch sein). Was das LLM sonst raten muesste
  (Endpunkte/IDs/Payload) gehoert INS Skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:18:26 +02:00
duffyduckandClaude Opus 4.8 0e42cf775f fix(router): geraete-gezieltes Spotify-Play → Claude (8B fummelt Transfer-Flow)
Log-Beweis: Qwen baute fuer 'auf android abspielen' einen erfundenen Endpoint
(/v1/me/player/start) mit Platzhalter-Device-ID -> 404. Device-Transfer ist ein
komplexer Mehrschritt-Flow (Geraete abfragen -> Name->echte ID -> richtiger
Endpoint); das 8B ist da unzuverlaessig. Router schliesst 'auf <Geraet>'-
Formulierungen jetzt aus dem lokalen Pfad aus -> Claude. Simple Steuerung
(pause/next/play, 'was laeuft') + 'auf deutsch' bleiben lokal (verifiziert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:14:36 +02:00
duffyduckandClaude Opus 4.8 0e0c5d742f docs(skill): robustere fast_patterns-Anleitung fuer skill_create/update
ARIA soll Patterns wortstellungs-tolerant + mit Synonymen/Fuellwoertern in EINEM
Regex schreiben (z.B. 'pause spotify' UND 'spotify pause' UND 'stopp mal'), statt
nur einer Formulierung. Plus der Schluessel-Hinweis: Fast-Paths nur fuer die
HAEUFIGSTEN Befehle — den Rest faengt das lokale LLM (hat das Tool) schnell ab,
also lieber wenige breite Patterns als viele enge. Damit kriegen kuenftige
Skills automatisch robuste Patterns (statt manuellem Nachpatchen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:10:44 +02:00
duffyduckandClaude Opus 4.8 5b20bc1743 fix(local-llm): lokale Antwort nicht in <voice> wickeln (Anzeige war leer)
Qwen imitierte aus dem Kontext den <voice>-TTS-Block, packte aber die GANZE
Antwort hinein -> Display strippt <voice> -> leere Bubble (nur TTS klang richtig).
Fix: (1) Local-Prompt verbietet <voice>/[FILE:]/<tool_call>/Markup explizit —
nur Plain-Text (wird angezeigt UND vorgelesen). (2) Sicherheitsnetz: Voice-Tags
aus lokalen Antworten strippen, falls das 8B es doch tut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:09:57 +02:00
duffyduckandClaude Opus 4.8 c3652d2f8e fix(local-llm): web_search local-only — Claude behaelt seine staerkeren Web-Tools
web_search war versehentlich in META_TOOLS -> Claude bekam es zusaetzlich zu
seinen nativen WebSearch/WebFetch/Bash (redundant/verwirrend). Jetzt als
WEB_SEARCH_TOOL nur im lokalen Tool-Set (_build_local_tools). Claude nutzt
weiter seine eigene Suche + Voll-Seiten-WebFetch (wichtig fuer Pentest/Research);
SearXNG ist fuer das lokale Tier, das sonst keinen Netzzugriff haette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:53:03 +02:00
duffyduckandClaude Opus 4.8 3cd7350160 feat(local-llm): B1b — lokale Tools (web_search via SearXNG, Timer, Spotify, Memory)
Das lokale Tier kann jetzt Werkzeuge nutzen — Wetter/News/Fakten laufen lokal
statt ueber Claudes langsamen Web-Fetch.

- SearXNG-Container (aria VM, aria-net): self-hosted Meta-Suche, keyless,
  JSON-API aktiviert (aria-data/searxng/settings.yml), Rate-Limiter aus.
  Brain-Env SEARXNG_URL.
- web_search-Tool in META_TOOLS + _dispatch_tool (_web_search fragt SearXNG,
  gibt Titel/Snippet/URL der Top-Treffer). Steht auch Claude zur Verfuegung.
- Lokaler Tool-Loop (_try_local_fast_lane): kuratiertes Set web_search /
  memory_search / trigger_timer / run_spotify; max 3 Runden, sonst → Claude.
  Break-/Escalate-Guard bleibt.
- Router: should_try_local schliesst nur noch CLAUDE-ONLY-Themen aus (Bild,
  Skill, Projekt, OAuth, Licht, Kalender/Mail). Wetter/Timer/News/Musik/Memory
  gehen jetzt lokal. Verifiziert (alle Testfaelle korrekt).
- Schlanker Local-Prompt mit Tool-Guidance ("nur nutzen wenn noetig, sonst
  Smalltalk direkt beantworten"). Skill-BAUEN bleibt Claude-only.

Deploy: ARIA-VM brain+bridge+searxng, Gamebox llm-adapter (tool-passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:54 +02:00
duffyduckandClaude Opus 4.8 a58aa5594d feat(local-llm): B1b-Plumbing — tools/tool_calls durch Adapter/Bridge/Brain-Client
Traegt OpenAI-Tool-Definitionen (tools) durch den ganzen lokalen Pfad und gibt
tool_calls zurueck:
- adapter.py: tools -> llama.cpp /v1/chat/completions (tool_choice=auto),
  message.tool_calls zurueck in llm_response.
- aria_bridge.py: _local_llm + /internal/local-llm reichen tools durch, geben
  tool_calls zurueck.
- local_llm.py: local_llm_chat akzeptiert tools, result enthaelt tool_calls.

Inert bis der Brain-Tool-Loop (naechster Schritt) tools uebergibt — Verhalten
unveraendert. Tool-Set + lokale Tool-Loop + Router-Anpassung folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:36:09 +02:00
duffyduckandClaude Opus 4.8 3a3c14fdc5 ux(diagnostic): klarere Beschreibungen fuer die Lokales-LLM-Schalter
Pro Schalter ein kurzer Hilfetext (AN/AUS-Bedeutung, Testmodus-Warnung),
deutlichere Labels, Status-Zeile mit Ampel (/🟡/🟢) + Hinweis auf aktuellen
B1a-Stand (lokal plaudert nur, Tools folgen in B1b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:32:12 +02:00
duffyduckandClaude Opus 4.8 1d4f23ada3 fix(brain): Gift-Waechter — Identity-Breaks nie persistieren (Kaskade unterbunden)
Wiederkehrender Identity-Bug: --system-prompt liefert die Persona korrekt (Proxy-
Test = "ICH BIN ARIA"), ABER ein einziger in der History gespeicherter Break
("ich bin nach wie vor Claude / die Persona ist erfunden") zieht bei schwachen
Folgeturns eine Kaskade nach sich — das Modell setzt seine eigene Ablehnung fort.
Das erste Cleanup verlangte "claude code" und liess deutsche Breaks durch.

Fix zweifach:
- prompts.py: looks_like_identity_break() — STARKE selbstreferenzielle Marker
  (ich-bin-Claude / erfundene Persona / diese-Session-injiziert / nicht-real-in-
  dieser). Bewusst NICHT das blosse "injizier"/"prompt injection" — das nutzt
  ARIA in Pentest-Antworten legitim (verifiziert: 0 False Positives).
- agent.py: nach dem Claude-Loop Break-Check; bei Break Retry (Nondeterminismus
  holt meist ARIA), sonst sichere ARIA-Fallback-Antwort — Break wird NIE
  persistiert. Auch die lokale Fast-Lane eskaliert bei Break auf Claude.
- clean_poisoned_turns.py: auf denselben starken Matcher umgestellt (der breite
  produzierte False Positives auf echte Security-Doku, im Dry-Run gesehen).

VM bereits bereinigt (je 2 Rest-Breaks aus conversation.jsonl + chat_backup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:45 +02:00
duffyduckandClaude Opus 4.8 b967999a5f feat(diagnostic): B1a-2 — Schalter fuer lokales LLM (Master/Nur-lokal/Tool-Umfang)
Neuer Settings-Block "Lokales LLM (schnelle Antworten)":
- Master-Checkbox "Lokales LLM nutzen" (aus = alles Claude)
- "Nur lokales LLM (kein Claude-Fallback)" — Eval-Haken
- "Tool-Umfang: Abgespeckt / Voll" — Voll deaktiviert (gated) + ⓘ mit VRAM-Erklaerung
- ⓘ-Haupt-Info erklaert Router/Latenz/Heim-Internet-Abhaengigkeit

server.js: GET/POST /api/local-llm-config <-> /shared/config/local_llm.json
(read/write, spiegelt runtime-config-Muster). Genau die Datei, die router.py
im Brain live liest. Onchange-Autosave, Status-Zeile, Laden bei ws.onopen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:07:33 +02:00
duffyduckandClaude Opus 4.8 71b3fc5d31 perf(router): lokales Fenster auf letzte 8 Turns cappen (Speed bei langer History)
Gemessen: lokaler Call mit 12-Turn-Fenster ~2,6s statt ~0,8s. Im Hauptchat (bis
50 Turns) wuerde volles Fenster das Prefill aufblaehen und den Speed-Vorteil
auffressen. Lokales Tier ist fuer kurze Plauder-Turns — letzte 8 Turns reichen.

B1a end-to-end verifiziert: plaudern→lokal (~0,8s warm), Tool/hart→Claude,
localOnly erzwingt lokal; Config live gelesen, Default aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:00:35 +02:00
duffyduckandClaude Opus 4.8 c8b7ea322e feat(router): B1a — lokale Fast-Lane (reden-only) mit Schaltern, gated (Default aus)
Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns → lokales Qwen
(schlanker Prompt, KEINE Tools) in <1 s; sonst Claude wie bisher.

- router.py: load_config() liest /shared/config/local_llm.json (enabled/localOnly/
  toolVariant; Default enabled=false → alles Claude). should_try_local() Heuristik
  (kurz + keine Tool-/Technik-Marker; localOnly erzwingt lokal). build_local_
  system_prompt() = schlank (IDENTITY_ANCHOR + Schnell-Modus + Awareness-Liste +
  <<ESCALATE>>-Regel, keine Tool-Schemas).
- agent.py: _try_local_fast_lane() — baut schlanken Prompt + Window, ruft
  local_llm_chat; leer/ESCALATE → None (→ Claude), sonst Antwort + persistiert.
  localOnly: kein Claude-Fallback (Eval), ehrliche Fehlermeldung bei Nichterreich.
  In chat() nach User-Turn gated aufgerufen.

Heuristik lokal verifiziert (alle Testfaelle korrekt). Gated off → laufendes
Verhalten unveraendert bis Master-Schalter an. Diagnostic-Toggles (B1a-2) +
lokale Tools (B1b) folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:55:29 +02:00
duffyduckandClaude Opus 4.8 f93dd58a68 docs: Verschieben-Button reboot-sicher via Platzierungs-Config + dummem Reconcile-Agent
Button = remote start/stop = braucht Agent pro Host, aber dumme Variante:
gpu_placement.json als Single Source of Truth, Agent reconciled nur (Mensch =
Scheduler). Loest die Reboot-Falle (Laufzeit-Move vs statische Config). Deploy:
Code ueberall via git, Config entscheidet Platzierung. Nach B0/B1; Fallback =
COMPOSE_PROFILES manuell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:45:56 +02:00
duffyduckandClaude Opus 4.8 c72d10cf58 docs: ENTSCHIEDEN — manuelle GPU-Platzierung (Compose-Profiles) + read-only Dashboard
Semi-Auto-Controller verworfen (Host wechselt selten). Pin via Compose-Profiles
pro Host (.env COMPOSE_PROFILES), Verschiebe-Regel up-neu + rm-alt gegen
Reboot-Wiederauferstehung, GPU-Dashboard aus Heartbeats (read-only). Skaliert
auf Gamebox3/4x3060 ohne Orchestrator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:41:22 +02:00
duffyduckandClaude Opus 4.8 291335250a docs: "Waechter"/Orchestrator gestaffelt (billiger Heartbeat vs teure Steuerung)
Idee Container-start/stop auf RVS-Hosts: billiger Teil (Registrierung/Heartbeat
-> Flotten-Sichtbarkeit + Router-Erreichbarkeit) lohnt bald; teurer Teil
(Agent+Controller+Placement = Mini-Nomad) erst bei groesserer Flotte, dann eher
Swarm/Nomad statt Eigenbau. Fuer 2 Gameboxen: statische Platzierung + llama-swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:36:46 +02:00
duffyduckandClaude Opus 4.8 eebafe8895 docs: Skalierung/Multi-GPU/Cluster + Diagnostic-VRAM-Info (Klarstellung: kein VRAM ueber RVS)
Roher VRAM ist nicht ueber RVS teilbar (Relay, nicht GPU-Bus). Echte Pfade:
mehr Karten/Box = VRAM-Pool (volles Arsenal), mehr GPU-Hosts = Modell-Server
ueber RVS (Cluster), llama-swap = geteilter Server im Host. Plus geplantes
Diagnostic-Info-Icon mit VRAM-Bedarf pro Ausbaustufe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:30:17 +02:00
duffyduckandClaude Opus 4.8 25a9b71482 docs: lokales LLM — Awareness (volle Liste) vs Authority (kuratierter Satz)
Klargestellt: lokales Modell ist ueber ALLES im Bilde (kurze Awareness-Liste im
System-Prompt -> gezieltes Escalieren), darf aber nur den sicheren Satz
ausfuehren. Harte Grenze ist Kontext/VRAM (volles Schema ~15-20K Tokens passt
nicht in 8K, 32K-Kontext sprengt die geteilte 12GB-3060), nicht Misstrauen.
Claude im RZ mit 200K-1M Kontext kann sich das ganze Arsenal leisten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:24:53 +02:00
duffyduckandClaude Opus 4.8 e6e87a8b20 docs: lokales LLM bekommt kuratierte Tool-Auswahl (Tool-Calling in B1 statt B3)
Qwen3 kann natives OpenAI-Tool-Calling; llama.cpp (--jinja) unterstuetzt es.
Lokales Tier bekommt einen kleinen, risikoarmen Start-Satz (Wetter, memory_search,
trigger_timer, Spotify, Licht); volles Arsenal bleibt bei Claude, Escalation-Netz
bleibt. Klein halten = Speed. Plan-Entscheidungen + Phasen entsprechend gezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:20:52 +02:00
duffyduckandClaude Opus 4.8 75b022a456 docs: B1-Router bekommt "Nur lokales LLM"-Modus (Eval-Checkbox, kein Claude-Fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:18:18 +02:00
duffyduckandClaude Opus 4.8 436759306e docs: B0.5-Wuensche festgehalten (Modell-Status/aktiviert + Testchat-Zeile in Diagnostic)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:12:10 +02:00
duffyduckandClaude Opus 4.8 45e63b6def feat(local-llm): B0 Consumer — Bridge-Relay + Brain-Client (spiegelt FLUX)
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp,
1:1 nach dem FLUX-Roundtrip-Muster gebaut:
- Bridge: _pending_llm (requestId→Future), llm_response-Handler (setzt Future),
  _local_llm() (sendet llm_request, wartet mit 30s-Timeout), HTTP-Route
  POST /internal/local-llm ({messages, max_tokens?, temperature?, stop?}).
- Brain: local_llm.py mit local_llm_chat() — POSTet an die Bridge, gibt
  {ok, content, model?, elapsedMs?} zurueck, wirft nie (Aufrufer eskaliert
  bei ok=false auf Claude).

Provider-Kette bereits verifiziert (718ms). Als naechstes: Test brain→bridge→
gamebox end-to-end, dann B1 (Router-Heuristik + Escalation) und der
Diagnostic-Testchat/Status (B0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:11:49 +02:00
duffyduckandClaude Opus 4.8 03e5c5f9a1 fix(local-llm): Qwen3-Thinking im Adapter aus (schnelles Tier grübelt nicht)
Qwen3 hat Thinking default AN -> verbraet Tokens im <think>-Block, liefert bei
kleinem max_tokens leeren content und ist ~3x langsamer (2,2s statt 0,7s im
Test). ARIAs lokales Tier soll fixe Antworten geben, nicht grübeln (grübeln =
harter Turn = Claude). Adapter setzt daher chat_template_kwargs
{enable_thinking:false}; abschaltbar via LLM_DISABLE_THINKING=false.

Verifiziert end-to-end (RVS->Adapter->llama->Qwen3): "Hallo! Ich bin bereit."
in 718ms Round-trip RZ<->Gamebox@home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:05:51 +02:00
duffyduckandClaude Opus 4.8 b5ba54d05f feat(local-llm): B0 — GGUF Auto-Download via llama.cpp -hf (kein manuelles Ablegen)
Statt einer manuell abgelegten Datei zieht llama.cpp das Modell beim ersten
Start selbst von Hugging Face (-hf Qwen/Qwen3-8B-GGUF:Q4_K_M, offizielles
Repo verifiziert) und cached es unter xtts/models (persistent). Modell/Quant
via LLM_HF_REPO/LLM_HF_QUANT in der .env wechselbar, kein Code.

Diagnostic-Modellauswahl (on-demand laden/aktivieren mehrerer Modelle) als
Folge-Baustein B0.5 via llama-swap ins Plan-Doc aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:33:59 +02:00
duffyduckandClaude Opus 4.8 98c78af7ad feat(local-llm): B0 — Gamebox llama.cpp + RVS-Adapter (Provider-Seite)
Plan B, Phase B0 (Provider): lokales Qwen3-8B auf der Gamebox, angebunden
per RVS wie f5tts/whisper (kein IP-Pflegen, nur URL+Token).

- xtts/llm-adapter/: RVS-Client (spiegelt whisper-bridge: TLS+ws-Fallback,
  Reconnect-Backoff), nimmt llm_request, ruft llama.cpp /v1/chat/completions
  lokal, antwortet llm_response (korreliert per requestId). Nicht-streamend
  in B0; llm_partial fuer B2 reserviert.
- xtts/docker-compose.yml: neue Services `llama` (llama.cpp server-cuda,
  GGUF via ./models, OpenAI-API auf :8081) + `llm-adapter`.
- rvs/server.js: ALLOWED_TYPES += llm_request/llm_response/llm_partial.
- GGUF (mehrere GB) via .gitignore aus dem Repo; xtts/models/ mit .gitkeep.

Topologie-Hinweis: Gamebox@home, ARIA@RZ -> Bounce ueber Internet ist
unvermeidbar (Voice macht's schon so); Router faellt bei Nichterreichbarkeit
per Escalation auf Claude zurueck. Consumer-Seite (Bridge-Relay + Brain-
Client + Router) kommt als naechstes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:43:46 +02:00
duffyduckandClaude Opus 4.8 79dab81a77 docs: Plan B — lokaler LLM-Router (Gamebox Qwen3 via RVS) neben Claude
Design-Doc: schnelles lokales LLM fuer die einfachen ~80% der Turns (<1s,
gratis auf Gamebox-GPU), Claude nur fuer die schweren 20%. Anbindung ueber
den RVS-Token-Room wie TTS/STT (kein IP-Pflegen), Router mit Heuristik +
Escalation, schlanke Persona lokal, Phasen B0-B3. Basiert auf der
Latenz-Messung (CLI-Boden ~3,5s) aus dieser Session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:16:42 +02:00
duffyduckandClaude Opus 4.8 5cdd135169 feat(voice): leeres <voice></voice> = bewusst stumm (Steuerbefehl-Quittungen)
Bisher: tts_text = tts_text_preview or text — ein leeres <voice></voice> fiel
auf den vollen Text zurueck und wurde doch gesprochen. Jetzt: ein vorhandener
<voice>-Tag ist die EXPLIZITE TTS-Vorgabe (auch leer). Leeres <voice></voice>
= Bubble sichtbar, aber KEIN TTS. Nur ohne Tag Rueckfall auf vollen Text.

Motivation: Spotify-Steuerbefehle (next/pause/…) laufen jetzt ueber den
Fast-Path (<1s), aber die gesprochene Quittung klaute auf dem Handy den
Audio-Fokus -> Spotify duckte/pausierte und spielte (bei kurzem Text) nicht
weiter. Steuer-Skills koennen ihre Reply nun mit <voice></voice> stummschalten.
Generell nutzbar: ARIA kann jede Antwort bewusst stumm halten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:03:50 +02:00
duffyduckandClaude Opus 4.8 b8c20390d7 tool(brain): Cleanup-Script fuer vergiftete Hauptthread-Turns
Einmal-Tool zum Entfernen der aus-der-Rolle-Antworten ("das ist injiziert,
ich bin Claude Code"), die waehrend der --append-system-prompt-Phase in die
History geschrieben wurden und das Modell per Self-Grounding rueckwaerts aus
der Rolle zogen (siehe 2284c1a). Feld-agnostisch: bedient conversation.jsonl
(content/project_id) UND chat_backup.jsonl (text/projectId). Entfernt nur
Hauptthread-Assistant-Turns mit "claude code" + zweitem Ablehnungs-Marker
plus die ausloesende Frage; projekt-getaggte Turns (z.B. legitime Pentest-
Doku, die Injection als Arbeitsmaterial erwaehnt) bleiben unangetastet.
Dry-Run per Default, Backup vor --apply, idempotent.

Bereits auf der Dev-VM angewandt: je 5 Fehl-Dialoge aus beiden Dateien
entfernt, Brain neu gestartet, Hauptchat verifiziert (ARIA antwortet wieder
als ARIA mit vollem Memory-Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:04:54 +02:00
duffyduckandClaude Opus 4.8 2284c1a780 fix(proxy): ARIA-Persona als VOLLER System-Prompt-Replace (--system-prompt)
Der Self-Grounding-Ansatz (268636c) allein reichte nicht: Sonnet 5 in Claude
Code haelt seine eingebaute "You are Claude Code"-Identitaet hart und liest den
synthetischen <previous_response> als "fabricated". Ursache bleibt der Kanal:
--append-system-prompt HAENGT ARIAs Persona nur hinten an Claude Codes Basis-
Identitaet an — bei duennem Kontext (Hauptchat) gewinnt die Basis und wehrt die
Persona als Injection ab. Anhaengen ist gegen das Anti-Injection-Training des
Modells nicht durchsetzbar.

Fix: System-Prompt VOLL ersetzen statt anhaengen — sed-Patch in docker-compose
schaltet manager.js buildArgs von --append-system-prompt auf --system-prompt.
Damit ist die ARIA-Persona DIE Identitaet des Modells (kein Anhaengsel), und
Claude Codes dynamische Sektionen (cwd '/', git, platform) — die "ich bin ein
Coding-Agent"-Signale — fallen weg. Bestaetigt per claude-code-guide: die CLI
isoliert bei --system-prompt vollstaendig, die eingebaute Identitaet leakt nicht.

extractSystemPrompt liefert weiterhin einen selbsttragenden Prompt (Persona +
Anker + Memory + Skills + Tool-Block); er darf bei --system-prompt nie leer sein
(Brain schickt immer System-Message + Tools -> real nie leer). Der IDENTITY_SEED
aus 268636c bleibt als Defense-in-Depth drin. Kommentare in openai-to-cli.js,
routes.js, agent.py, prompts.py entsprechend nachgezogen.

Deploy: Proxy UND Brain neu (--force-recreate; routes.js/openai-to-cli.js werden
frisch in den Proxy-Container ge-cp't). Verifikation am Container:
docker exec aria-proxy sh -c 'grep -o "\-\-system-prompt" /usr/local/lib/*/claude-max-api-proxy/dist/subprocess/manager.js'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:44:02 +02:00
duffyduckandClaude Opus 4.8 268636c030 fix(brain): ARIA-Identitaet im Hauptchat — Self-Grounding im Konversations-Strom
Symptom: Im Hauptchat antwortete statt ARIA der Basis-Proxy-Claude ("I'm
Claude Code (Sonnet 5), I'm not adopting that persona") und flaggte GPS +
Tool-XML als Prompt-Injection. Im Projekt lief alles normal.

Ursache: Die Persona geht ueber --append-system-prompt zu — das HAENGT sie
nur hinten an Claude Codes eigene "You are Claude Code"-Basis-Identitaet an,
ersetzt sie nicht. Ob ARIA in der Rolle bleibt, entscheidet dann der
Konversations-Strom (stdin): reiche Projekt-Historie voller ARIA-
<previous_response>-Turns haelt die Rolle; der duenne Hauptchat-Verlauf
(bzw. der erste Turn eines neuen Projekts) nicht -> Basis-Identitaet gewinnt.
Der IDENTITY_ANCHOR (a0e8c23) ist inert, weil er im *angehaengten* Teil steht.

Fix: synthetischen ersten ARIA-Turn (IDENTITY_SEED) in ihrer eigenen Stimme
an den Anfang des Konversations-Stroms setzen. Das Modell setzt seine EIGENE
etablierte Stimme fort (Self-Grounding) — und weil es ein <previous_response>
ist und kein <system>-Tag, ist es kein Injection-Trigger. Rein ephemer, wird
nie in die Conversation persistiert. Gilt fuer alle Turns, deckt damit auch
die erste Frage eines frischen Projekts ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:37:30 +02:00
duffyduckandClaude Opus 4.8 de4d95a392 feat(diagnostic): Model-Tier-Liste aus /shared/config/models.json (editierbar ohne Code)
handleModels liest die kuratierte Tier-Liste jetzt pro Request aus
/shared/config/models.json statt sie hart zu codieren. Neue Tier-Namen oder
angepasste Beschreibungen sind damit eine reine Datei-Aenderung — kein
Code-Edit, kein Proxy-Neubau, kein Neustart (Datei bearbeiten → im Diagnostic
„↻ Aktualisieren").

- Fehlt/kaputt die Datei: eingebaute Defaults greifen und werden einmalig als
  models.json angelegt, damit es was zu editieren gibt.
- ids bleiben MODEL_MAP-kompatibel (extractModel), Validierung: nicht-leeres
  Array mit String-id, sonst Fallback auf Defaults.
- UI-Hinweis auf den Dateipfad ergaenzt.
- Load/Seed-Logik simuliert verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:17:38 +02:00
duffyduckandClaude Opus 4.8 44f4067834 feat(diagnostic): Sprachmodell per Tier-Dropdown waehlen (Opus/Sonnet/Haiku)
ARIA laeuft ueber das Claude-Max-Abo via CLI — waehlbar ist der Tier, nicht
eine feste Modellversion (die CLI nimmt automatisch das aktuelle Modell des
Tiers). Kein API-Key → keine echte Anthropic-Models-API; daher kuratierte Liste.

- proxy routes.js handleModels: kuratierte /v1/models mit tier/display_name/
  description (ids bleiben MODEL_MAP-kompatibel: claude-sonnet-4/opus-4/haiku-4).
- diagnostic server.js: neuer GET /api/models-list — holt die Liste vom Proxy
  (Frontend erreicht den Proxy nicht direkt).
- Frontend: Dropdown statt Freitext + „↻ Aktualisieren"-Button; markiert das
  aktive brainModel (get_model), zeigt Beschreibung, „Setzen" schreibt brainModel
  und weist auf Brain-Neustart hin. Freitext-Override bleibt unter „Erweitert".
  Liste wird bei WS-Connect automatisch geladen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:13:17 +02:00
duffyduck 1b48307907 release: bump version to 0.2.0.3 2026-07-10 21:40:25 +02:00
duffyduckandClaude Opus 4.8 f34a7863db docs(changelog): Projekte/Multi-Threading-Epos + aktuelle Session nacherfasst
Der Changelog hing bei 0.0.0.5 (2026-03), Projekt lief bis 0.2.0.2 (2026-07).
Nachgetragen: [Unreleased] (Identitaets-Anker, Proxy-System-Prompt, kontext-
getaggte Activity + kontext-scoped Cancel, Diagnostic Datei-Zuordnung) und der
zusammenhaengende Projekte-/Multi-Threading-Epos (0.1.9.7–0.2.0.2). Luecke
0.0.0.6–0.1.9.6 bewusst nicht rueckwirkend nacherfasst (Hinweis im Header).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:33:29 +02:00
duffyduckandClaude Opus 4.8 3fbd7eb9fb feat(multitask): kontext-getaggte Activity + kontext-scoped Cancel (Restpunkte 1+2)
Beide Restpunkte teilen eine Verkabelung: die projectId fliesst jetzt bis zum
Proxy und zurueck in die agent_activity-Events.

Restpunkt 1 — per-Kontext Activity-Indikator:
- Brain: proxy_client.chat_full(project_id) → payload.aria_project_id;
  agent.py gibt active_project_id mit.
- Proxy routes.js: liest aria_project_id, taggt Tool-/Stream-Hooks damit,
  trackt Subprozesse pro Kontext.
- Bridge: _emit_activity(project_id) + payload.projectId; /internal/agent-activity
  reicht projectId durch; send_to_core/_process_core_response taggen thinking/idle
  mit dem Turn-Kontext.
- App: agentActivityByCtx-Map; „ARIA denkt"-Indikator zeigt nur den
  fokussierten Kontext statt global zu flackern.

Restpunkt 2 — kontext-scoped Cancel (Barge-In):
- Proxy: neuer /cancel {projectId} killt NUR die Subprozesse eines Kontexts
  (_cancelByProject); /cancel-all bleibt fuers NOT-AUS.
- Bridge: soft cancel_request → _cancel_proxy_for_project(projectId) statt des
  toten Diagnostic /api/cancel. Hard bleibt /cancel-all.
- App: cancel_request + Abbrechen-Button tragen die fokussierte projectId.

Damit laufen Kontexte in der App echt parallel: Arbeit in Projekt A wird durch
Senden/Abbrechen in Hauptchat oder Projekt B nicht mehr abgewuergt, und der
Indikator gehoert zum sichtbaren Chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:29:59 +02:00
duffyduckandClaude Opus 4.8 fa0eb13e0c feat(proxy): ARIA-Persona ueber echten System-Prompt-Kanal (--append-system-prompt)
Statt System/Persona als <system>-getaggten User-Content in den Prompt zu
falten (was das Modell als Prompt-Injection fehldeutete und ARIA aus der Rolle
warf), geht sie jetzt ueber den ECHTEN System-Prompt-Kanal der Claude-CLI.

- openai-to-cli.js: openaiToCli liefert prompt = NUR Verlauf (conversationToPrompt),
  systemPrompt = Persona + Tool-Block (extractSystemPrompt, ohne <system>-Tags).
- docker-compose: neue sed-Zeile schleust "--append-system-prompt",options.systemPrompt
  ins buildArgs-Array von manager.js (gleicher Stil wie die bestehenden
  Patches; options ist dort in scope). routes.js reicht systemPrompt bereits
  an subprocess.start durch (b9150f2).

systemPrompt ist immer ein String (extractSystemPrompt → "" statt undefined),
also kein spawn-Crash bei leerem System. sed-Transformation gegen echten
buildArgs simuliert → valides JS-Array verifiziert.

Rollback falls die CLI-Version --append-system-prompt nicht kennt: die eine
sed-Zeile aus docker-compose entfernen + openaiToCli.prompt zurueck auf
messagesToPrompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:05:48 +02:00
duffyduckandClaude Opus 4.8 885e825f8b fix(app): Barge-In kontext-scoped — Hauptchat-Frage killt/blockiert Projekt-Arbeit nicht mehr
Bug: waehrend ARIA in einem Projekt arbeitet, im Hauptchat eine Frage stellen →
sie wurde angezeigt aber nicht verarbeitet (erst wenn das Projekt fertig war).
Proxy kann nachweislich parallel (2 claude-Subprozesse) — der Flaschenhals war
die App: interruptAriaIfBusy wertete den GLOBALEN agentActivity aus und feuerte
bei jedem Senden-waehrend-irgendein-Kontext-arbeitet einen kontext-uebergreifenden
cancel_request.

Fix: Busy-Status kontextgenau aus queueStatus (/projects/queue-status) statt
global. Barge-In (Brain-Cancel) nur noch wenn GENAU der fokussierte Kontext
arbeitet; TTS wird weiterhin immer gestoppt wenn ARIA spricht. cancel_request
traegt jetzt die projectId (fuer spaeteres kontext-scoped Cancel in der Bridge).

Damit laufen Hauptchat und Projekt(e) in der App parallel — passend zum
Multi-Threading im Brain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:58:01 +02:00
duffyduckandClaude Opus 4.8 b9150f2b46 prep(proxy): System-Prompt fuer echten CLI-Kanal separat bereitstellen
Vorbereitung fuer die saubere Variante: ARIA-Persona ueber den ECHTEN
System-Prompt-Kanal der Claude-CLI (--append-system-prompt) zustellen statt als
<system>-getaggten User-Content (den das Modell als Prompt-Injection wertet und
der ARIA aus der Rolle wirft — siehe a0e8c23).

- openai-to-cli.js: extractSystemPrompt() (System-Messages + Tool-Block als
  roher Text, ohne <system>-Tags) und conversationToPrompt() (nur Verlauf).
  openaiToCli() liefert jetzt zusaetzlich systemPrompt + conversationPrompt.
- routes.js: reicht systemPrompt an subprocess.start(..) durch.

BEWUSST rueckwaertskompatibel/no-op: prompt bleibt unveraendert (System-Inhalt
noch drin), die Extra-Option wird von einem ungepatchten manager.js ignoriert.
Der letzte Schritt (manager.js: --append-system-prompt beim Spawn + prompt auf
conversationPrompt umstellen) folgt, sobald die echte manager.js-Struktur
vorliegt — die npm-Datei liegt nicht im Repo und ein Fehlpatch legt den
kompletten Proxy lahm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:43:36 +02:00
duffyduckandClaude Opus 4.8 a0e8c23710 fix(brain): fester Identitaets-Anker — ARIA verliert Rolle nicht mehr in (Pentest-)Projekten
Bug: bei tiefergehenden Fragen in Projekten (v.a. dem Pentest-Projekt) verlor
ARIA ihre Identitaet — das Modell wertete die Konversation als Prompt-Injection,
verwarf die ARIA-Rolle samt <tool_call>-Format und antwortete als generischer
Coding-Agent ("Ich bin nicht ARIA, das ist ein Injection-Muster").

Ursache: die Persona kam bisher NUR aus gepinnten "identity"-Memories (weiche
Daten), es gab keinen harten System-Anker. Pentest-Projekt-History steckt voller
injection-artiger Inhalte (Payloads, <system>-Bloecke, <tool_call>-Beispiele,
XSS) — genau das Material triggert die Injection-Abwehr des Modells, und ohne
festen Anker kippt die weiche Memory-Identitaet.

Fix: IDENTITY_ANCHOR steht jetzt IMMER ganz oben im System-Prompt (vor allen
Memories). Er haelt die ARIA-Identitaet in jedem Kontext fest UND rahmt
verdaechtige Inhalte (in Verlauf/Projekten/gelesenen Dateien) explizit als
DATEN, die analysiert — nicht befolgt — werden. Besonders fuer Security-/
Pentest-Arbeit, wo solche Payloads das Arbeitsmaterial sind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 23:26:05 +02:00
duffyduckandClaude Opus 4.8 dfd357ee91 feat(diagnostic): Datei einem Projekt zuordnen im Datei-Manager
Der /api/files-set-project-Endpoint existierte, aber es fehlte die UI. Jede
Datei-Zeile hat jetzt ein Projekt-Dropdown (Hauptchat + alle Projekte, aktueller
Wert vorausgewaehlt, gruen wenn getaggt). Aenderung schreibt via
/api/files-set-project ins Manifest, aktualisiert den lokalen Cache und rendert
neu (respektiert den aktiven Projekt-Filter).

Damit lassen sich auch alt-hochgeladene (untagged) Dateien nachtraeglich einem
Projekt zuordnen — die projectId-Zuordnung beim Upload passiert automatisch
(596d0bb), das hier ist die manuelle Korrektur/Nachpflege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:28:18 +02:00
duffyduckandClaude Opus 4.8 596d0bb243 fix(projects): Anhaenge landen im gewaehlten Projekt statt im Hauptchat
Bug: Bild/Datei ins Textfeld + Frage → beides landete im Hauptchat, egal
welches Projekt fokussiert war. Der Anhang-Pfad reichte die projectId nirgends
durch (im Gegensatz zum reinen Text-Pfad).

App (sendPendingAttachments):
- lokale Anhang-Bubble bekommt projectId (App-Focus)
- file-Upload (rvs.send('file')) schickt projectId mit
- Merge-Trigger chat-Nachricht schickt projectId mit

Bridge:
- file-Handler liest payload.projectId, merkt sie (_pending_files_project_id)
  und taggt die Datei per _tag_file_to_project ins richtige Projekt
- _flush_pending_files_with_text(user_text, project_id): reicht die projectId
  aus dem chat-Payload an send_to_core (Fallback: gemerkter Upload-Kontext)
- _flush_pending_files_after (Files-only): nutzt den gemerkten Upload-Kontext
- merged-Aufruf im chat-Handler gibt payload.projectId mit

Damit tragen User-Bubble, Datei-Manifest, Brain-Turn und ARIA-Antwort alle
denselben Projekt-Tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:38:47 +02:00
duffyduck 5a7bfd9f50 release: bump version to 0.2.0.2 2026-07-06 10:30:39 +02:00
duffyduckandClaude Opus 4.8 5410371b9c fix(voice): App uebernimmt Server-projectId der STT-Bubble — App/Diagnostic-Sync
Symptom: Frage im Projekt gestellt, Antwort nur in der App sichtbar, Frage nur
in der Diagnostic — Frage und Antwort scheinbar in verschiedenen Kontexten.

Ursache: die App taggt die lokale Voice-Bubble optimistisch mit dem App-Focus,
waehrend der Server (Voice-Router) die autoritative Zuordnung macht. Weichen
die ab (Registry-Race aus 3e88eec, Sticky, „fuer X:"-Prefix, Fallback), landet
die lokale App-Bubble in einem anderen Kontext als die serverseitig getaggte
Frage/Antwort → App- und Diagnostic-Ansicht divergieren.

Fix: beim Eintreffen des sender=stt-Broadcasts uebernimmt die App die
projectId des Servers fuer die Bubble (alle drei Match-Zweige). Nur wenn das
Feld mitkommt — altes Bridge-Format ohne projectId → App-Focus behalten.
Primaerursache bleibt 3e88eec (Router liefert wieder den korrekten Kontext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:28:35 +02:00
duffyduck b27fba316b release: bump version to 0.2.0.1 2026-07-06 00:34:43 +02:00
duffyduckandClaude Opus 4.8 648698d202 fix(voice): robusteres STT-Endpointing via akustische Stille (nicht nur semantisch)
Bug: die Spracherkennung bricht mal zu frueh ab (nach zwei Worten obwohl noch
gesprochen wird) und merkt mal gar nicht dass man aufgehoert hat.

Ursache: der Endpointer feuerte rein auf semantischer Stagnation (Whisper-
Transkript waechst endpoint_ms lang nicht). Das ist fragil — kurze Sprech-
Pausen oder beam_size=1-Instabilitaet → vorzeitiger Cut; Oszillation/
Halluzination → nie ein Endpoint.

Fix: akustische Stille als robustes Primaersignal. Jeder Tick (~200ms) misst
die RMS-Energie der letzten 300ms:
- Solange Sprach-Energie da ist, bleibt die Session am Leben (kein Cut mitten
  im Reden).
- Endpoint feuert wenn seit endpoint_ms keine Sprach-Energie mehr da ist.
- Semantischer Backstop (2x endpoint_ms) fuer laute Umgebungen (Auto), wo die
  Energie nie faellt — dort degradiert es sauber aufs bisherige Verhalten.

RMS-Threshold (0.012) gegen Stille/Sprache/Fahrgeraeusch verifiziert.
Konstanten oben in der Datei tunebar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:31:48 +02:00
duffyduckandClaude Opus 4.8 3e88eecd9c fix(voice): Sprach-Nachricht landet im richtigen Projekt (Registry-Race)
Bug: bei manuellem Stop landete die Sprachnachricht (und ARIAs Antwort) im
Hauptchat statt im fokussierten Projekt — mal so, mal so, je nachdem ob der
Endpoint natuerlich kam oder man den Stop-Button drueckte.

Ursache: stt_stream_end popte die Focus-projectId-Registry SOFORT. Nach einem
manuellen Stop folgt aber noch der finale stt_endpoint (Whisper-Final-
Transcribe), der die projectId aus genau dieser Registry braucht. War sie schon
weg → focused_pid="" → Voice-Router faellt auf Hauptchat zurueck. Die
User-Bubble (lokal getaggt) blieb im Projekt, ARIAs Antwort kam aber mit
project_id="" → nur in EINEM Kontext sichtbar.

Fix: stt_stream_end raeumt die Registry nicht mehr sofort auf, sondern per
verzoegertem call_later(20s) als Leak-Schutz. Der stt_endpoint-Handler popt
selbst wenn er zuerst kommt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:31:48 +02:00
duffyduckandClaude Opus 4.8 e33d1c782f fix(diagnostic): ARIA-Datei-Bubbles (Bilder) tragen project_id — kein Ausblenden mehr
Regression aus dem chat_history-projectId-Fix: nach dem History-Reload laeuft
jetzt updateChatVisibilityByFocus(). addAriaFile() setzte aber kein
dataset.projectId → Datei-/Bild-Bubbles galten als Hauptchat und wurden
ausgeblendet, sobald der (aus localStorage wiederhergestellte) Focus auf einem
Projekt lag. In der App trat das nicht auf (datengetriebener Filter mit
korrektem projectId).

- addAriaFile(): setzt dataset.projectId aus p.projectId und respektiert beim
  Live-Anhaengen den aktuellen Focus (wie addChat via hiddenByFocus).
- chat_history-Renderer: reicht m.projectId an addAriaFile weiter.
- Bridge _process_core_response: haengt turn_pid als f["projectId"] an die
  file_from_aria-Payload, damit der Live-Pfad die Zuordnung kennt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:46:25 +02:00
duffyduckandClaude Opus 4.8 1568c25ac4 fix(projects): Backfill v2 — robuster Match trotz GPS/Barge-In/FILE-Marker
v1 matchte text==content exakt und verfehlte damit Nachrichten, bei denen die
Bridge den Brain-Text anreichert oder cleant:
- User-Turns: _build_core_text PREPENDT [Hinweis: Barge-In]-/[GPS-Position]-
  Bloecke — die stehen in conversation.jsonl, nicht in chat_backup.
- Assistant-Turns: conversation enthaelt [FILE: /shared/uploads/...]-Marker,
  chat_backup hat sie schon rausgecleant.

_norm entfernt jetzt FILE-Marker + fuehrende [..]-Bloecke, kollabiert
Whitespace und vergleicht einen 120-Zeichen-Praefix. Neuer Marker (v2) →
laeuft einmal neu und fuellt die von v1 verbliebenen Luecken. Nicht-destruktiv
(eigene .pre-backfill-v2.bak), bestehende Tags bleiben unangetastet.

Mit Tests gegen GPS-/Barge-In-/FILE-Marker-Faelle verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:34:17 +02:00
duffyduckandClaude Opus 4.8 97ee455ab4 fix(diagnostic): chat_history traegt project_id — getaggte Bubbles landen im Projekt
Der Reload-Pfad des Dashboards warf die Kontext-Zuordnung an ZWEI Stellen weg,
weshalb nach jedem Reload alles im Hauptchat lag (Projekte leer) — unabhaengig
davon ob chat_backup korrekt getaggt war:

- server.js (History-Builder): pushte {type,text,meta,ts} ohne project_id.
  Jetzt projectId aus obj.project_id fuer sent/received/aria_file mitgegeben.
- index.html (chat_history-Renderer): setzte dataset.ts aber nie
  dataset.projectId → jede Bubble galt als Hauptchat. Jetzt gesetzt, und nach
  dem Neuaufbau updateChatVisibilityByFocus() aufgerufen damit der aktuelle
  Kontext-Focus sofort greift.

Zusammen mit der Backfill-Migration (8b567e1) erscheinen alt-getaggte Turns
jetzt beim Dashboard-Reload im richtigen Projekt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:26:50 +02:00
duffyduck cd72068e76 release: bump version to 0.2.0.0 2026-07-03 02:22:58 +02:00
duffyduckandClaude Opus 4.8 8b567e15bf feat(projects): Migration — alt-getaggte Nachrichten nachtraeglich in Projekte einsortieren
Projekt-Nachrichten aus der Zeit vor dem chat_backup-project_id-Feld (getaggt in
conversation.jsonl seit fc0f91d, aber chat_backup fuehrte project_id erst ab
f51ad15) lagen in der UI im Hauptchat statt im Projekt. Die App/Diagnostic
zeigen aus chat_backup.jsonl — dort fehlte der Tag.

Neue Einmal-Migration (Brain-Lifespan) schreibt project_id aus conversation.jsonl
per (role, text)-Match reihenfolge-erhaltend nach chat_backup.jsonl zurueck:
- idempotent via Marker /shared/config/.chat_backup_projectid_backfill_v1
- nicht-destruktiv: legt .pre-backfill-v1.bak an, setzt nur LEERE project_ids,
  entfernt/aendert nie einen bestehenden Tag
- atomar (tmp + os.replace)
- Duplikate: Deque je (role, normtext) inkl. "" fuer Hauptthread → korrekte
  Zuordnung auch bei wiederholten Texten, kein faelschliches Taggen von
  Hauptchat-Interleaving

Mit Logik-Tests (Zuordnung, Duplikat-Reihenfolge, Idempotenz) verifiziert.
Nachrichten aus der Zeit bevor es Projekte gab bleiben untagged im Hauptchat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:20:41 +02:00
duffyduckandClaude Opus 4.8 64c06db308 fix(diagnostic): keine untagged ARIA-Bubbles/Backup-Writes mehr (leere Projekte)
Das Dashboard hatte dieselbe Ursache wie die App (untagged Nachrichten in
chat_backup) PLUS zwei eigene untagged-Pfade ueber den Gateway-Watch:

- Frontend: chat_final rendert keine Chat-Bubble mehr. ARIA-Antworten kommen
  ausschliesslich via rvs_chat (traegt projectId). chat_final stammt vom
  Gateway-Watch ohne projectId → erzeugte eine Duplikat-Bubble im Hauptchat.
- server.js: kein chat_backup-Write im Gateway-final-Handler mehr. Die Bridge
  (_process_core_response) ist massgeblicher Writer und schreibt MIT project_id;
  der Write hier erzeugte untagged Duplikate, die beim Reload im Hauptchat
  statt im Projekt landeten.

Der App-Focus-Fix (63dde65) sorgt dafuer dass neue Nachrichten ueberhaupt
getaggt in chat_backup landen — davon profitiert das Dashboard direkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:15:02 +02:00
duffyduckandClaude Opus 4.8 63dde6506f fix(projects): Drawer resettet App-Focus nicht mehr (leere Projekte-Ursache) + Zurueck-Button
Kernbug: ProjectsBrowser.load() pushte bei JEDEM Drawer-Oeffnen status.active
in den App-Focus. Im Multi-Threading hat das Brain keinen globalen
active_project-State mehr → status.active ist null → Focus wurde auf Hauptchat
zurueckgesetzt. Folge: nach jedem Drawer-Oeffnen landeten alle Nachrichten im
Hauptchat, Projekte blieben leer.

- ProjectsBrowser: neues Prop currentFocusId (App-Focus = Source-of-Truth).
  load() uebernimmt nur noch die Projektliste, kein onActiveChanged(status.active)
  mehr. Highlight (✓ FOCUS) folgt currentFocusId.
- ChatScreen: currentFocusId={focusedProjectId} durchgereicht.
- ChatScreen: direkter „← Hauptchat"-Button im Focus-Header (nur im Projekt
  sichtbar) — ein Tap statt Drawer→Hauptchat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:07:33 +02:00
duffyduck 5fb08b4ea5 release: bump version to 0.1.9.9 2026-07-03 01:53:25 +02:00
duffyduckandClaude Opus 4.7 d49ec64e27 fix(voice-router): Voice folgt App-Focus + „hauptmenü" als back-to-main
Zwei Bugs aus dem ersten Live-Test des Multi-Threading-Designs.

Bug 1 — Voice ignorierte App-Focus:
Stefan hat in Projekt X reingeguckt und was reingesagt — Message landete
im Hauptchat statt in X. Der Voice-Router auf der Bridge kannte den
sichtbaren Kontext der App nicht.

Fix:
- audio.ts.startStreamingRecording nimmt neuen opts.projectId und
  schickt es im stt_stream_start-Payload mit.
- ChatScreen.tsx: alle 4 startStreamingRecording-Callsites (wake,
  barge-in, passive, manuell) uebergeben focusedProjectIdRef.current.
  Neuer useRef-Spiegel damit die Focus-ID auch in useCallbacks/
  useEffects mit alten Closures aktuell bleibt.
- aria_bridge.py: neuer Handler fuer stt_stream_start speichert die
  projectId in self._stt_stream_projects[requestId], stt_stream_end
  loescht wieder. Beim stt_endpoint wird sie an _process_endpoint_text
  weitergereicht und dort als default_project_id in den Voice-Router.
- _apply_voice_router bekommt neuen Prio-Rank 4: „App-Focus als
  Default" — greift wenn kein Meta, kein Prefix und kein aktiver Sticky.
  So folgt Voice ohne extra Marker dem sichtbaren Kontext.

Bug 2 — Back-to-Main-Regex zu eng:
„zurück ins hauptmenü" wurde nicht als Meta erkannt (Regex matchte nur
„zurück zum hauptchat") und landete deshalb im aktiven Sticky-Projekt.

Fix: Regex akzeptiert jetzt auch hauptmenü, menü, haupt, main mit
Praepositionen „zum/zur/ins/in den".

Bonus — Burger-Button heller:
Stefan konnte den ☰-Toggle im Header kaum sehen. Farbe von Default
(dunkelgrau) auf #E0E0F0 (hell) mit fontWeight 700 gesetzt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 01:50:50 +02:00
duffyduck 882f3def99 release: bump version to 0.1.9.8 2026-07-03 01:31:58 +02:00
duffyduckandClaude Opus 4.7 092f085254 feat(bridge): Voice-Router — 30s-Sticky + Meta-Command-Interception
Phase 4 vom Multi-Threading-Redesign — der Voice-Layer routet STT-Text
per-Projekt und lässt Meta-Kommandos gar nicht erst ans Brain.

Voice-Router in _process_endpoint_text():
- „zurueck zum hauptchat" / „hauptchat bitte" / „aria hauptchat"
  → Sticky reset, project_changed(exited) broadcasten, KEIN Brain-Call.
- „fuer <name>: <text>" (Fuzzy-Match auf Projekt-Namen ≥ 0.6 Score)
  → Sticky auf gefundene project_id + Rest des Texts geht ans Brain
  im Projekt-Kontext. project_changed(entered) broadcasten damit
  App/Diagnostic den Focus mit umschalten.
- Sticky-Timeout 30s: eine Voice-Message ohne Prefix innerhalb des
  Fensters bleibt im Sticky-Projekt, refresht das Timeout. Nach Ablauf
  → Default Hauptchat.
- Meta-Kommandos aendern KEINEN Brain-State — ARIAs Arbeit in laufenden
  Projekten wird nicht abgebrochen.

send_to_core wird jetzt mit dem gerouteten project_id gerufen; das Brain
bekommt den Text im richtigen Queue-Kontext.

Broadcast-Chain: Voice-Router setzt Sticky → project_changed geht via RVS
an App+Diagnostic → Focus-Header/Kontext-Strip wechseln automatisch.

Damit ist der komplette Multi-Threading-Redesign abgeschlossen:
- Brain: per-Request project_id + per-Projekt Queue + Queue-Aware Prompt
- Bridge: Chat-Routing + Voice-Router
- App: Focus-One + Drawer + Status-Dots
- Diagnostic: Kontext-Strip + Focus-Filter
- Voice: Sticky + Meta-Interception

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:59:02 +02:00
duffyduckandClaude Opus 4.7 21eac63723 feat(diagnostic): Multi-Threading UI — Kontext-Strip + Focus-Filter + Queue-Polling
Phase 3 vom Multi-Threading-Redesign. Diagnostic zeigt einen scrollbaren
Streifen von Kontext-Karten ueber dem Chat (Hauptchat + Projekte), jede
mit Live-Status-Dot. Tap wechselt den Focus, Chat filtert auf diesen
Kontext, Sende-Input laeuft mit der Focus-ID durch Bridge → Brain-Queue.

index.html:
- Neuer <div id="chat-context-strip"> ueber der Chat-Box, horizontal
  scrollbar.
- JS: focusedContextId (in localStorage gespiegelt), diagQueueStatus,
  diagProjectsCache. renderContextStrip() zeichnet Karten mit Dot
  + Status-Label. switchDiagFocus(id) wechselt Focus + versteckt
  Bubbles anderer Kontexte via data-project-id + style.display.
- Polling: /api/brain/projects/queue-status alle 2s, /projects/list
  alle 15s.
- addChat: nimmt options.projectId → schreibt data-project-id an die
  DOM-Node, versteckt sofort wenn Focus abweicht.
- Chat-Reception-Handler propagiert p.projectId aus dem RVS-Payload.
- testRVS() sendet msg.projectId=focusedContextId mit.

server.js:
- sendToRVS(text, isTrace, projectId): neuer Param, wird in
  payload.projectId gesetzt → Bridge routet an /chat body.project_id.
- test_rvs-Handler reicht msg.projectId durch.

Bewusst nicht drin (Follow-up wenn Stefan mag):
- Voller Dashboard-Stack mit stacked Karten die eigene Message-Listen +
  Input-Felder haben. Aktuelle Variante ist „Kontext-Strip fuer schnellen
  Wechsel + Focus-One-Rendering" — ~90% des UX-Werts mit ~10% des Aufwands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:56:30 +02:00
duffyduckandClaude Opus 4.7 06316da36f feat(app): Multi-Threading UI — Focus-One-View + Drawer + Queue-Status-Dots
Phase 2 vom Multi-Threading-Redesign. Chat zeigt jetzt genau EINEN Kontext
(Hauptchat oder Projekt X) — die anderen laufen im Brain weiter, sichtbar
nur ueber Status-Dots im Drawer.

ChatScreen:
- Reorder-Trick + collapsible Project-Bloecke raus. messagesForRender filtert
  jetzt direkt auf focusedProjectId.
- Neuer Focus-Header oben: ☰ Drawer-Toggle + Kontext-Name + Status-Dot
  (gruen idle / gelb queue / rot arbeitet). Drawer-Icon kriegt ein Badge
  mit der Anzahl OTHERE aktiver Kontexte.
- Focus in AsyncStorage gespiegelt — Neustart restauriert den letzten Blick.
- brainApi.getProjectQueueStatus() alle 2s gepollt fuer Status-Dots.
- project_changed-Event steuert Focus-Wechsel (App-lokal, kein Brain-Roundtrip).

brainApi:
- Neuer Typ QueueContextStatus + ProjectQueueStatus.
- Methode getProjectQueueStatus() → /projects/queue-status.

ProjectsBrowser:
- Nimmt queueStatus als Prop, rendert Status-Dot pro Zeile (Hauptchat +
  Projekte).
- switchTo ruft NICHT mehr brainApi.switchProject (kein globaler active
  mehr) — direkt onActiveChanged mit dem Projekt-Objekt aus der Liste,
  schliesst danach die Modal.
- Label ✓ FOCUS statt ✓ AKTIV — praeziser fuer's neue Modell.

SettingsScreen:
- File-Manager-Filter-Default nutzt AsyncStorage statt Brain-Query.

Bewusst nicht drin (Follow-up):
- OS-Push wenn Projekt fertig ist — braucht Firebase-Setup, kommt separat
  wenn die visuellen Dots nicht reichen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:52:14 +02:00
duffyduckandClaude Opus 4.7 7927ad05ae feat(brain): Multi-Threading via per-request project_id + per-project queue
Erster Schritt zum echten Multi-Threading fuer ARIA-Projekte. Kein globaler
active_project-State mehr — jeder /chat-Request sagt selbst welche Buehne
(project_id im Body). Verschiedene Projekte laufen parallel, gleiches
Projekt queued via asyncio.Lock.

Backend:
- ChatIn.project_id: Client bestimmt pro Request wohin. Bridge routet.
- /chat: async, holt per-Projekt asyncio.Lock. Requests fuers gleiche
  Projekt reihen sich in _project_pending ein, warten am Lock. Requests
  fuer verschiedene Projekte laufen echt parallel.
- Neuer /projects/queue-status endpoint: pro Kontext (inkl. Hauptchat
  unter __main__): busy True/False + queue_size. Fuers UI-Status-Dots.
- Agent.chat() nimmt project_id + pending_queue Params. Kein
  projects_mod.get_active() mehr im Hot-Path.

Queue-Aware Prompting:
- Wenn nach dem aktuellen Turn weitere Nachrichten in der Queue liegen,
  wird der System-Prompt um ein QUEUE-Segment erweitert mit Instruktion:
  „Bevor Du den aktuellen Task loesst, pruef die Queue — widerspricht/
  annuliert eine spaetere Nachricht? Dann Skip-Antwort statt Doppelarbeit."
- Beispiel: Task 'titelleiste rot' + Queue-Tail 'doch nicht, blau'
  → ARIA skipt rot, blau kommt als naechste Anfrage sauber durch.
- Kein extra LLM-Call — reine Prompt-Injection.

Project-Tools:
- project_enter/exit sind jetzt UI-Signale (App wechselt Ansicht via
  project_changed event), aendern KEINEN Brain-State mehr. Der aktuelle
  Turn bleibt in seinem Chat-Kontext.
- project_list zeigt keinen "AKTIV"-Marker mehr (nicht mehr sinnvoll).
- projects_mod.set_active/get_active bleiben als Legacy-Helpers (kein
  Aufruf mehr aus dem Hot-Path).

Bridge:
- send_to_core packt project_id in den /chat-Body.
- User-Backup-Eintrag tag't project_id sauber, keine Brain-Query mehr.

Naechste Schritte (kommende Commits):
- App: Focus-One-View mit Drawer + Status-Dots + OS-Push
- Diagnostic: Dashboard-Stack mit Karten
- Voice-Router: 30s-Sticky + Meta-Command-Interception im wakeword.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 17:57:30 +02:00
duffyduck 5b2c552a88 release: bump version to 0.1.9.7 2026-06-16 09:38:11 +02:00
duffyduckandClaude Opus 4.7 f51ad1547d fix(projects): project_id im Chat-Backup persistieren + 1 Block pro Projekt
Zwei Stefan-Reports nach dem ersten Live-Test:

1. App-Reload verlor die Projekt-Bloecke
   - chat_backup.jsonl hatte keine project_id-Felder, also kamen die
     Bubbles als Hauptchat zurueck wenn die App ueber chat_history_response
     ihre History neu lud.
   - Fix: aria_bridge schreibt jetzt project_id in jeden Backup-Eintrag.
     Assistant-Reply via turn_pid (aus ChatOut.project_id); User-Message
     via payload.projectId (oder Brain-Status-Query als Fallback fuer
     Trigger-Replies / Diagnostic-Sends).
   - App: chat_history_response-Mapper liest m.project_id → ChatMessage.projectId.

2. Raus + rein in ein Projekt erzeugte einen zweiten Block am Ende
   - Vorher: Gruppierung bei aufeinanderfolgenden gleich-getaggten Bubbles.
     Hauptchat dazwischen hat den Block "unterbrochen", neuer Block.
   - Fix: neue reorderedMessages-Stufe sortiert Messages so um, dass alle
     eines Projekts contiguous werden, verankert am LATEST-Activity-
     Timestamp des Projekts. Genau EIN Block pro projectId — bei
     Re-Enter wandert der existierende Block ans Zeitende der Liste,
     die neue Bubble haengt unten in der Gruppe.
   - Hauptchat-Bubbles bleiben chronologisch zwischen den Projekt-Blöcken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:36:11 +02:00
duffyduck 2a2700907c release: bump version to 0.1.9.6 2026-06-13 22:09:56 +02:00
duffyduckandClaude Opus 4.7 93ecbf6c43 fix(projects): ARIA-generierte Dateien dem aktiven Projekt zuordnen
Bisher haben nur App-Uploads (msg_type == "file") ein Projekt-Tag bekommen.
Dateien die ARIA waehrend des Turns selbst schreibt (via [FILE: /shared/
uploads/aria_xyz.pdf]-Marker) sind dem Hauptchat zugefallen, auch wenn
Stefan in einem Projekt war.

Fix: Beim Verarbeiten der ARIA-Antwort in _process_core_response wird die
turn_project_id aus payload.projectId (ChatOut.project_id vom Brain) ge-
nutzt um jede gefundene ARIA-Datei sofort zu taggen, bevor sie als
file_from_aria broadcast wird.

Helper-Split:
- _tag_file_to_project(path, pid): pure Write, pid schon bekannt
- _tag_file_to_active_project(path): Convenience-Wrapper, fragt Brain
  nach active project (genutzt vom App-Upload-Handler, der noch nichts
  vom Projekt-Kontext weiss)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 22:08:23 +02:00
duffyduck d430fa113e release: bump version to 0.1.9.5 2026-06-13 21:56:57 +02:00
duffyduckandClaude Opus 4.7 1fb512c2fd fix+feat(projects): Spinner-Bug, Back-Button, kollabierbare Chat-Bloecke, File-Filter
Drei Stefan-Bugs aus dem ersten Deploy-Test plus die fehlenden Polish-
Features fuer die Projekt-Funktion.

Fixes:
- ProjectsBrowser-Spinner-Hang: useRef-Pattern statt useCallback([onActive
  Changed]) — Parent uebergibt inline-arrow-Callbacks, neue Identitaet
  jedes Render → useCallback recomputes → useEffect refeuert → infinite
  Spinner. Fix: Ref-Bridge fuer Callbacks, useCallback mit empty deps.
- ChatScreen Banner: zusaetzlicher × Hauptchat-Button rechts (sichtbar
  nur wenn Projekt aktiv) — ein Tap und zurueck zum Hauptthread, ohne
  Modal-Umweg.

Features:
- Brain ChatOut.project_id: aktive Projekt-ID NACH dem Turn (kann
  durch project_enter/exit-Tools waehrend Turn gewechselt sein). Bridge
  liest sie aus dem /chat-Response und haengt sie an jeden ARIA-Chat-
  Broadcast als payload.projectId.
- App: ChatMessage.projectId-Feld. User-Bubbles werden mit aktiver
  Projekt-ID getaggt vor dem Senden (auch im RVS-Payload). ARIA-Bubbles
  kriegen die ID vom Bridge.
- App: Chat-Verlauf rendert aufeinanderfolgende Project-Messages als
  einklappbaren Block mit Header (▶/▼ + Projekt-Name + Count). Auto-
  Collapse beim Projekt-Wechsel (altes ein, neues aus), Default beim
  ersten Render: alle inaktiven Projekte eingeklappt.
- File-Manager Project-Tagging:
  - diagnostic/server.js: Manifest /shared/config/file_projects.json
    + /api/files-list returnt projectId pro Datei + neuer Endpoint
    /api/files-set-project.
  - bridge/aria_bridge.py: nach App-Upload Auto-Tag mit aktivem Projekt
    (Brain-Status-Query, best-effort fail-silent).
  - App SettingsScreen: scrollbare Projekt-Pill-Reihe als Filter, default
    auf aktives Projekt wenn vorhanden, sonst "Alle Projekte".
  - Diagnostic: zweites Dropdown im Files-Tab, baut Projekt-Optionen
    dynamisch aus /api/brain/projects/list.

Bewusst nicht drin (Folgeschritt):
- Per-File "Projekt zuweisen"-Action (Long-Press / Right-Click)
- Filter-Sync zwischen ChatScreen-Banner und SettingsScreen-Filter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 21:55:02 +02:00
duffyduck 1baa1a7a08 release: bump version to 0.1.9.4 2026-06-13 13:54:12 +02:00
duffyduckandClaude Opus 4.7 fc0f91d1e6 feat(projects): Threads im Hauptchat verankert (Stefan-Konzept)
Projekte sind benannte Thema-Bündel die voice-gesteuert via Brain-Tools
geöffnet/verlassen werden. Default-Mode bleibt der Hauptthread — Projekte
sind eine optionale Bühne. Anchored-not-replaced: App-Open landet immer
im Hauptchat, Projekte sind nur sichtbar wenn aktiv betreten.

Brain:
- projects.py: CRUD + Fuzzy-Find + Active-State-Pointer
  (/shared/config/projects.json + active_project.txt).
- conversation.py: Turn.project_id-Feld + window(project_id) Filter.
- agent.py: 6 Meta-Tools — project_create / _enter / _exit / _list /
  _summary / _end. chat() liest aktive Projekt-ID, taggt User+Assistant-
  Turns damit, filtert das LLM-Window auf Projekt-Kontext und ergaenzt
  den System-Prompt um den aktiven Projekt-Hinweis. touch_project pflegt
  last_activity_at + turn_count.
- main.py: REST-Endpoints /projects/{status,list,create,switch,
  {id}/end,{id}/archive, PATCH /{id}}.

Bridge + RVS:
- aria_bridge.py: project_changed Event-Propagation Brain → RVS-Broadcast
  damit App + Diagnostic ihre Banner refreshen.
- rvs/server.js: project_changed in ALLOWED_TYPES.

App:
- brainApi.ts: Project-Type + 6 API-Methoden.
- ProjectsBrowser.tsx (neue Komponente, ~340 Zeilen): Status-Header,
  Hauptchat als Erster-Eintrag, Projekt-Liste mit Aktiv-Marker, Long-Press
  zum Editieren, Modals fuer Neu/Edit/End/Archiv.
- ChatScreen.tsx: Banner unterhalb des Status-Bars zeigt aktives Projekt
  oder „Hauptchat" — Tap öffnet ProjectsBrowser als Modal. Aktive Projekt-
  Info wird bei Mount + bei project_changed-Events refreshed.
- SettingsScreen.tsx: Neue Section 📁 „Projekte" zeigt ProjectsBrowser inline.

Diagnostic:
- Neue Sektion im Brain-Tab mit Liste, Aktiv-Marker, Beenden/Archivieren
  pro Zeile, Modal fuer Neu. Lädt automatisch bei Brain-Tab + bei
  project_changed-Event-Broadcast.

Was bewusst NICHT drin ist (Folgeschritte):
- Per-Message Filter im Chat-Verlauf (zeigt aktuell alle Bubbles, Banner
  zeigt Kontext) — App müsste Chat-History per project_id filtern.
- Files-by-Project Tagging.
- Inline-Collapse-Bloecke im Chat-Verlauf.
- Sub-Projekte (Stefan-Entscheidung: weglassen, „Mama-tauglich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 13:51:26 +02:00
duffyduck f714cfc336 release: bump version to 0.1.9.3 2026-06-06 21:11:50 +02:00
duffyduckandClaude Opus 4.7 a0dc0cf20e feat(speaker-id): Phase 5 — Passive-Listen-Window nach jeder Konversation
Neuer State 'listening' im WakeWordService. Nach endConversation faellt
ARIA nicht direkt zu armed zurueck, sondern ins passive Lauschen fuer
PASSIVE_LISTEN_DEFAULT_MS (Default 30s, in AsyncStorage konfigurierbar).
In dem Fenster braucht Stefan kein Wake-Word mehr — er kann einfach
weitersprechen, Speaker-ID-Gating in der Whisper-Bridge filtert fremde
Stimmen (TV, Frau, Hintergrundgespraeche).

Flow:
  armed → wake → conversing → TTS → resume → (Nichts gesagt) →
  endConversation → enterPassiveListening('listening' + Timer) →
  startPassiveStreamingRecording (kein User-Bubble, kein wake-ready-Sound)
  → Speaker-ID-Gating in Bridge → Speech detected:
    exitPassiveListening('speech') → 'conversing' → normaler Flow
  → Nichts in N Sek:
    Timer feuert → exitPassiveListening('timeout') → 'armed' (Wake an)

Implementation:
- wakeword.ts: WakeWordState += 'listening'. enterPassiveListening +
  exitPassiveListening + onPassiveListen-Callback + Cancel-Timer-Hooks
  in stop(). PASSIVE_LISTEN_DEFAULT_MS/STORAGE_KEY + load/save Helpers.
- ChatScreen.tsx: state-Type um 'listening' erweitert. State-Listener
  schliesst Conversation-Focus auch in 'listening' (Spotify bleibt
  pausiert). onPassiveListen → startPassiveStreamingRecording mit
  noSpeechTimeoutMs=passiveMs. STT-Endpoint-Handler: bei text != ''
  und state=='listening' → exitPassiveListening('speech'); bei
  text == '' und state=='listening' → naechste passive Aufnahme.
  Beim Wechsel listening→armed/off: laufende streaming-Aufnahme
  cancellen damit OpenWakeWord beim Re-Arm das Mic kriegt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:51:07 +02:00
duffyduckandClaude Opus 4.7 ac53af5c24 feat(speaker-id): Phase 3 — Speaker-Gating im Streaming-STT
Sobald eine Streaming-Session ~1.5s Audio im Buffer hat, wird einmal pro
Session der Speaker-ID-Check ausgefuehrt (im Executor, ~50-100ms auf GPU).
Bei Match → Session laeuft normal weiter. Bei Mismatch → synthetisches
stt_endpoint mit text='' reason='speaker_mismatch' + stt_stream_done →
App ruft endConversation. Kein Whisper-Transcribe fuer fremde Stimmen →
Token + Latenz gespart.

- StreamSession: 3 neue Felder (speaker_checked, speaker_match,
  speaker_similarity).
- SessionManager._check_speaker / _finalize_speaker_mismatch:
  Check + sauberes Beenden bei Mismatch.
- _tick_session: Check-Gate vor STREAM_MIN_AUDIO_MS-Check eingehaengt.
- speaker_id.verify: threshold=None statt =DEFAULT_THRESHOLD damit
  config-Broadcast-Updates zur Laufzeit greifen (Default-Arg wird sonst
  zur Def-Zeit gebunden).

Fail-open: ohne Fingerprint returnt verify() (True, 0.0) — keine
Auswirkung. Stefan kann ohne Enrollment weiter wie bisher arbeiten.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:41:49 +02:00
duffyduckandClaude Opus 4.7 e3fe27f736 feat(speaker-id): Phase 2 — Enrollment-UI (App) + Voice-ID-Section (Diagnostic)
App-Seite:
- VoiceIdEnrollment.tsx (neue Komponente, ~370 Zeilen): Status-Karte
  (loading/unenrolled/enrolled/error), Sample-Recorder mit Countdown
  (4s fest pro Sample), Liste mit einzelnem Loeschen, Save-Button
  (disabled bis 5 Samples), Fingerprint-Delete mit Confirm.
- SettingsScreen.tsx: neue Section 🎤 'Stimme einrichten' zwischen
  Wake-Word und Sprachausgabe.
- Sample-Format: WAV via audioService.startRecording — wird
  whisper-bridge-seitig per wave-Modul gestrippt.

Diagnostic-Seite:
- Neue settings-section 'Voice-ID (Sprecher-Erkennung)': Status-Anzeige
  (live ueber voice_id_status_response), Threshold-Slider 0.30-0.70
  (persistiert in voice_config.json, broadcast als config-Message),
  Refresh + Delete-Button.
- server.js: 2 neue actions (voice_id_status, voice_id_delete),
  send_voice_config nimmt voiceIdThreshold mit auf.

Backend:
- speaker_id.py: _normalize_audio_bytes erkennt jetzt WAV-Header
  (RIFF/WAVE) und strippt auf rohes PCM — sonst werfen die ECAPA-
  Embeddings auf den 44-Byte-Header rein.
- bridge.py: config-Broadcast-Handler setzt voiceIdThreshold auf
  speaker_id.DEFAULT_THRESHOLD (wird erst in Phase 3 beim Gating
  genutzt, persistiert aber schon).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:36:06 +02:00
duffyduckandClaude Opus 4.7 6e19adab87 feat(speaker-id): Phase 1 — SpeechBrain ECAPA-TDNN Backend in whisper-bridge
Speaker-ID-Modul (Hermes-Style „echtes Gespraech ohne Wake-Word"-Vision,
Phase 1 von 5). Erkennt Stefans Stimme via 192-dim Embedding + Cosine-
Match gegen einen persistierten Fingerprint.

Module:
- speaker_id.py: lazy-loaded ECAPA-TDNN (HuggingFace), enroll/verify/
  status/delete. Fingerprint = L2-normalisierter Mittelwert aus N
  Enrollment-Samples in /voice-id/fingerprint.json.
  Fail-open: kein Fingerprint → verify() returnt (True, 0.0).
- bridge.py: 3 Message-Handler — voice_id_status_request,
  voice_id_enroll_request (samples[]: base64 16kHz int16 PCM),
  voice_id_delete_request. Enrollment laeuft im Executor (Torch
  blockt sonst die Event-Loop).
- Dockerfile: torch 2.3.1 + torchaudio mit CUDA-12.1-Wheels (sonst
  zieht speechbrain CPU-only Torch rein). Container ~1 GB groesser.
- docker-compose.yml: ./voice-id:/voice-id Bind-Mount fuer Fingerprint-
  Persistenz (ueberlebt Container-Restart).
- rvs/server.js: 6 neue Message-Types in ALLOWED_TYPES.

Phase 2 (next): App-Enrollment-Flow + Diagnostic-Voice-ID-Section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:26:12 +02:00
duffyduck 095a10aaf0 release: bump version to 0.1.9.2 2026-06-06 09:30:13 +02:00
duffyduckandClaude Opus 4.7 e3a224478d fix(wakeword): Mic an andere Apps freigeben (WhatsApp-Voicenote etc.)
Bug: ARIAs VOICE_COMMUNICATION-Lock liess WhatsApp-Sprachnachrichten,
Sprach-Suchen u.ae. nur Stille aufnehmen — Android-Audio-Policy gibt
der zweiten App formal Audio, liefert aber Null-Samples solange unsere
Pipeline aktiv ist.

Fix: AudioRecordingCallback (API 24+) registriert sich beim start() und
beobachtet andere Mic-Sessions. Fremder Mic-User detected → unsere
AudioRecord + Effects sofort freigeben (externallyPaused=true), WakeLock
+ Callback bleiben aktiv. Fremder weg → 300ms warten (Audio-Stack-
Settling), nochmal pruefen, dann reaktivieren.

Refactor mit drin: start()/stop() benutzen jetzt zwei Helper
(acquireAndStartRecording, stopAndReleaseRecording) damit die Mic-Setup-
Logik nicht zwischen start() und resumeAfterExternal() dupliziert wird.

Trade-offs:
- Wake-Word taub solange andere App das Mic nutzt — akzeptabel.
- API < 24: kein Callback verfuegbar, alter Stand (kein Mic-Sharing).
- Phone-Call kollidiert nicht mit phoneCallService.pauseForCall —
  beide pausieren/reaktivieren idempotent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 09:29:00 +02:00
duffyduckandClaude Opus 4.7 61c9183033 refactor(brain): Fast-Path als Skill-Capability — fast_patterns im Manifest
Frueher: Spotify-spezifische Patterns hardcoded in agent.py — jeder neue
Steuer-Skill haette wieder Brain-Code-Aenderungen gebraucht.
Jetzt: jeder Skill deklariert seine eigenen Patterns im Manifest unter
fast_patterns: [{match, args, reply}]. Brain iteriert generisch, kein
Skill bekommt Sonderbehandlung.

- agent.py: _try_skill_fast_path liest aus skills.list_skills(), keine
  Spotify-Konstanten mehr. skill_create/skill_update Tool-Schema kennt
  fast_patterns (mit Beispiel + Wann-nutzen-Hinweis).
- skills.py: _normalize_fast_patterns validiert Regex + filtert kaputte
  Eintraege; create_skill/update_skill akzeptieren das Feld.
- main.py: einmalige Lifespan-Migration — wenn spotify-Skill existiert
  und kein fast_patterns hat, werden die alten Hardcoded-Patterns
  rueberkopiert. Idempotent, laeuft bei jedem Restart sicher mehrfach.
- seed_rules.py: neue Regel `seed/skill-rule/fast-patterns-for-control`
  erklaert ARIA wann sie das Feature nutzen soll (reines Steuern: ja,
  kreativer Output / Parametrisierung: nein) — mit Beispiel.

Trade-off: Volume-Patterns (lauter/leiser) fallen aus dem Fast-Path raus,
weil die Multi-Step-Logik (GET state → compute → PUT) sich nicht
deklarativ ausdruecken laesst. Wer das zurueck will: Spotify-Skill um
einen action=volume_relative-Arg erweitern der die Mathe intern macht.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:56:15 +02:00
duffyduck e04bbef361 release: bump version to 0.1.9.1 2026-06-06 08:30:31 +02:00
duffyduckandClaude Opus 4.7 e82e07e3a2 fix: 5er-Bundle — Wake-Word, Spotify-Latenz, File-Limit, Connection-Refused
- WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event-
  Race + setBackground/setForeground statt setResumeCooldown im AppState.
- Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin
  emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr.
- Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track,
  pause, play, lauter, ...) matchen via Regex und gehen direkt aufs
  spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl.
- File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload +
  Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB.
- TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send
  fast-failt bei disconnected RVS statt 30s zu timeouten, und beide
  UIs reloaden automatisch beim Reconnect-Event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:27:08 +02:00
duffyduck 886b4409d2 release: bump version to 0.1.2.0 2026-06-02 15:22:02 +02:00
duffyduck bcea49365d feat(filemanager): 👁 Open + ⬇ Download pro Datei in App + Diagnostic
Stefan's UX-Wunsch: Datei direkt oeffnen ohne Umweg ueber Download.
Plus: in der App fehlte komplett der Per-Row-Download-Button (nur via
Checkbox + Bulk-Download). Beides jetzt gefixt.

App (SettingsScreen.tsx):
  - Neue per-Row-Buttons: 👁 Open + ⬇ Download + 🕒 Versionen + 🗑 Loeschen
  - Open-Pfad nutzt requestId-Praefix 'open-' im file_response-Handler
    → Datei wird nach CachesDirectory geschrieben (kein Storage-Bloat)
    → FileOpener-Native-Module (Intent.ACTION_VIEW mit MIME) oeffnet
      mit dem System-Picker → User waehlt PDF-Viewer / Galerie / Player
  - guessMimeFromName-Helper fuer den Intent damit Android die passende
    App findet
  - Download-Pfad unveraendert ('single-' Praefix), schreibt nach
    DownloadDirectory mit Suffix-Inkrement bei Namens-Konflikt

Diagnostic (server.js + index.html):
  - Neue Route /api/files-view (gleicher Code-Pfad wie files-download,
    aber Content-Disposition:inline + echter MIME-Type statt octet-stream)
  - Browser zeigt PDF / Bilder / Text im neuen Tab statt forcierten Download
  - 👁-Button in jeder File-Row neben ⬇/🕒/🗑
  - Fallback fuer unbekannte MIMEs: octet-stream → Browser bietet Download

Bei beiden Pfaden bleibt der Cache nutzbar: nach App-Open kann der User
die Datei im jeweiligen Viewer behalten; im Browser bleibt sie im Tab.
2026-06-02 14:55:24 +02:00
duffyduck 05eb7ed144 fix(whisper): Halluzinations-Filter — kein 'Untertitelung des ZDF' bei Stille
Stefan-Reproduktion: nach Wake-Word + ARIA-Antwort oeffnet das
Conversation-Window automatisch das Mikro fuer Follow-Up. Wenn Stefan
nichts sagt, ist das 4-8s Stille. Whisper halluziniert dann YouTube-
Untertitel-Patterns aus seinem Trainings-Corpus — gemessen 'Untertitelung
des ZDF, 2020' — und ARIA antwortet brav darauf. Endlos-Loop bis Stefan
manuell stoppt.

Fix in faster-whisper-transcribe:

1. Per-Segment no_speech_prob auswerten. Bei >= 0.6 (relativ konservativ:
   echte leise Sprache geht noch durch) → Segment verwerfen. Das eliminiert
   die offensichtlichen Halluzinationen schon zu 90%.

2. Bekannte Hallucination-Phrasen-Blacklist:
     - Untertitelung/Untertitel des ZDF (mit/ohne Jahr)
     - Amara.org community
     - Vielen Dank fuer's Zuschauen (mit allen Umlaut/Apostroph-Varianten)
     - Thanks for watching / Subs by ...
   Substring-Match (case-insensitive) auf normalisiertem Text (lowercase,
   Trailing-Punctuation und Jahres-Suffix '2020' weg).

3. Wenn ALLE Segmente einer Aufnahme rausgefiltert werden, ist text=''
   → App behandelt das via existierende no-speech-Pfad: Conversation-
   Window endet sauber, kein TTS-Echo-Loop.

Tradeoff: echte Phrasen wie 'Vielen Dank' allein gehen durch (Pattern
ist 'vielen dank fuer's zuschauen' — voller Match). Nur die bekannten
Halluzinations-Phrasen werden weggefiltert.

Falls in Zukunft neue Patterns auftauchen (Whispers Modell ändert sich):
einfach _HALLUCINATION_PHRASES erweitern, kein Brain-Restart noetig (lebt
in der Whisper-Bridge, die hot-reloaded werden kann).
2026-06-02 14:19:22 +02:00
duffyduck ddfc4261e5 fix(diagnostic): Versions-Liste dedupliziert via Blob-Hash — keine Restore-Duplikate
Stefan-Beobachtung: Wenn man V1 restored, taucht der neue Restore-Commit
als V4 in der Liste auf, mit identischem Inhalt wie V1. Bei mehrfachem
Hin- und Herrestoren wird die Liste schnell unuebersichtlich.

Fix: listVersionsForFile dedupliziert auf Blob-Hash-Ebene. Pro
inhaltlich identischer Datei-Variante wird nur der AELTESTE (= zuerst
in der History entstandene) Commit gezeigt. Restore-Commits werden
damit gefiltert da ihr Blob = der Blob eines aelteren Commits ist.

AKTIV-Marker wandert mit: vergleicht Blob der Working-Copy mit jedem
sichtbaren Eintrag — der Match-Eintrag bekommt isCurrent=true. So
zeigt das UI nach Restore "V1 ist AKTIV" obwohl im git ein neuer
V4-Commit existiert.

Implementation:
  - log --format=%H + ls-tree pro Commit → blob-hash sammeln
  - rueckwaerts durchgehen (chronologisch aelteste zuerst), seen-Set
    dedupliziert
  - Reverse fuer UI (neueste-zuerst)
  - git hash-object <working-copy> → currentBlob, mit jedem Eintrag
    vergleichen fuer den AKTIV-Marker
  - blob-Feld aus Response strippen (sieht aus wie zweite Commit-ID)

Audit-Trail bleibt im git intakt — Restore-Commits sind weiterhin
da, nur nicht im UI sichtbar. Falls jemals forensische Untersuchung
noetig: `git log` im /shared/uploads zeigt alle, inkl. Restore-Commits.
2026-06-02 13:59:42 +02:00
duffyduck 20e623dc37 feat(app): Versions-Historie pro Datei im App-Datei-Manager
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
  - pro Datei via 🕒-Button die Versions-Liste anzeigen
  - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
  - per ⟲ eine alte Version als neue aktive setzen (non-destructive,
    macht im Backend einen 'restore:'-Commit, die bisherige Version
    bleibt in der Historie)

Drei neue RVS-Message-Type-Paare:
  file_version_list_request    / _response
  file_version_download_request / _response (base64)
  file_version_restore_request  / _response

rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist.

bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic
(http://localhost:3001/api/files-versions / -version-content / -restore).
Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge
wrappt die Binary-Antwort als base64 fuer den RVS-Transport.

android/src/screens/SettingsScreen.tsx:
  - State versionsOpen/versionsList/-Loading/-Error
  - Drei rvs.onMessage-Branches fuer die neuen *_response Types
  - 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne)
  - Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject,
    formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag)
  - Restore-Button hat Confirm-Alert
  - Bei file_version_restore_response: list refresh + file-list refresh
2026-06-02 13:50:35 +02:00
duffyduck 6464dbe28c feat(diagnostic): Auto-Versionierung fuer /shared/uploads/ + Versions-UI
Stefan-Wunsch: ARIA-Aenderungen an Dateien sollen vom System (nicht
von ARIA selbst) automatisch versioniert werden. Im Datei-Manager:
Versionen auflisten, einzelne downloaden, oder als neue aktive Version
setzen (Restore = non-destructive neuer Commit).

Implementierung (alles im diagnostic-Container, da der eh schon
File-Handling kann):

1. Dockerfile: apk add git

2. server.js — Auto-Commit-Loop:
   - Beim Start: /shared/uploads als git-Repo initialisieren (idempotent;
     bestehendes .git wird uebernommen)
   - setInterval(30s): git status --porcelain → wenn dirty, add+commit
     mit "auto: <ISO-Timestamp>"-Message
   - Re-Entrancy-Guard fuer langsame git-Ops

3. server.js — drei neue HTTP-Routen:
   GET  /api/files-versions?path=X
     → [{hash, ts, subject, isCurrent}] aus git log --follow
   GET  /api/files-version-content?path=X&hash=Y
     → Binary-Stream der Datei aus diesem Commit (Content-Disposition
       attachment mit "name@<short-hash>.ext" als Default-Dateiname)
   POST /api/files-version-restore body={path, hash}
     → non-destructive: schreibt alten Inhalt als NEUE Version, neuer
       Commit "restore: <path> <- <short>". Aktive Version damit
       weiterhin rollback-bar.

4. index.html — Datei-Manager:
   - Pro Datei zusaetzlich 🕒-Button neben ⬇/🗑
   - Klick zeigt Modal mit Version-Liste (timestamp, short-hash,
     'AKTIV'-Marker fuer den jeweils letzten)
   - Pro Version: ⬇ Download + ⟲ Restore (mit Confirm)
   - Restore broadcasted file_version_restored damit Browser refreshen

Path-Safety: alle Pfade muessen relative-to-uploads sein, kein '..',
kein '/', kein '.git/'. Hash muss [0-9a-f]{7,40}.

.gitignore zunaechst keine — uploads/ ist eh nur User-/ARIA-Dateien,
kein Log-Noise erwartet. Falls Disk explodiert: spaeter ergaenzen.

Step-2 (App-Side via RVS-Messages) folgt im naechsten Commit, sobald
das hier in Diagnostic funktioniert.
2026-06-02 09:25:06 +02:00
duffyduck c38e1b197b release: bump version to 0.1.9.0 2026-06-01 18:26:17 +02:00
duffyduck 7a05e8233c debug(audio): RVS-Logs in _firePlaybackStarted + _releaseFocusDeferred
Stefan testet Spotify-Resume nach TTS, klappt noch nicht. Aktuell sehe ich
in den App-Logs nur 'PcmPlaybackFinished native event' aber NICHT ob
requestDuck / release / nudgeMediaResume jemals laufen.

Logge jetzt:
  audio.focus: 'TTS-start: requestDuck() called + canceled pending release'
  audio.focus: '_releaseFocusDeferred SKIPPED (conversation active)' (skip case)
  audio.focus: '_releaseFocusDeferred scheduled in 800ms'
  audio.focus: 'release timer fired but conversation now active → SKIP' (race)
  audio.focus: 'AudioFocus.release() now'
  audio.focus: 'nudgeMediaResume() now (50ms after release)'

Damit beim naechsten Stefan-Test eindeutig zuordenbar wo der Resume-
Pfad genau klemmt — feuern beide native Calls? Werden sie geskipped?
Greift der Cancel zu frueh? etc.
2026-06-01 11:44:11 +02:00
duffyduck 73d5bbd7be fix(proxy): Prompt an claude-CLI via stdin statt argv — fix Bad Gateway durch E2BIG
Wurzel: claude-max-api-proxy's subprocess/manager.js passt den Prompt
als letztes CLI-Argument an spawn('claude', [...args, prompt]). Bei
ARIA's groesseren Prompts (52 Messages + 24 Tools = ~80-100 KB) ueber-
schreitet das den Linux-Kernel-Limit fuer Argument-Listen → spawn
wirft E2BIG → Proxy gibt 500 zurueck → Brain wirft 502 → aria-bridge
wrappt als '[Brain-Fehler] HTTP Error 502: Bad Gateway' und sendet
das als Chat-Bubble + TTS. Stefan sieht 'Bad Gateway' und die App
spricht das auch noch aus.

Fix per zwei zusaetzlichen sed-Patches in docker-compose.yml die beim
Proxy-Start neben den bestehenden ausgefuehrt werden:

  1. Loescht die 'prompt, // Pass prompt as argument'-Zeile aus
     buildArgs() — claude-CLI bekommt den Prompt nicht mehr per argv
  2. Aendert this.process.stdin?.end() in start() zu
     this.process.stdin?.end(prompt) — Prompt wird nach Spawn via
     stdin geschrieben und stdin sofort danach geschlossen

Test: 'echo "test" | claude --print' funktioniert sauber. Stdin hat
kein Limit wie argv (E2BIG). Original-Kommentar 'more reliable than
stdin' war wohl von einer alten CLI-Version — aktuelles claude-CLI
reads stdin in --print mode korrekt.

Idempotent: beim Container-Restart sind die seds no-op (gemusterter
Code schon nicht mehr da).

Bonus-Wert: claude-max-api-proxy npm package muss man nicht patchen,
unsere Aenderungen ueberleben package-Updates (sed im compose-command).
2026-06-01 08:16:13 +02:00
duffyduck da38cdfefa release: bump version to 0.1.8.9 2026-05-31 23:54:23 +02:00
duffyduck 9c0c13d1f6 fix(audio): expliziter AudioFocus-Request beim TTS-Start — Spotify resumed wieder zuverlaessig
Stefan-Symptom: Spotify pausiert wenn ARIA zuhoert/spricht ✓, aber
resumed nach TTS-Ende NICHT (oder nur unzuverlaessig).

Diagnose aus dem Log-Trace:

  Manual-Button-Flow:
    recording start → AudioFocus.requestExclusive (Spotify pausiert)
    STT-Endpoint    → _cleanupStreamLocal → _releaseFocusDeferred → 800ms
                      Timer → release+nudge (Spotify wuerde resumen)
    Brain processing 50s lang...
    TTS-Start       → KEIN expliziter Focus-Request! AudioTrack-USAGE_
                      ASSISTANT pausiert Spotify nur IMPLIZIT (versions-
                      abhaengig). Wir wissen nicht ob Spotify gerade
                      gepaust ist.
    TTS-End         → release+nudge — aber wenn Spotify implizit-paused
                      ist, hat es keinen sauberen Focus-Owner gesehen
                      und nudge alleine reicht nicht zum Resume.

  Wake-Word-Flow:
    Aehnliches Problem wenn state schon armed ist beim TTS-Ende
    (vom 'no-speech in conv-window'-Pfad), dann ist mein
    endConversation ein noop → releaseConversationFocus laeuft nie,
    nur die PcmPlaybackFinished-Direkt-Release greift, hat aber
    dasselbe nudge-zu-schwach-Problem.

Fix in _firePlaybackStarted (audio.ts): EXPLIZITES AudioFocus.requestDuck
beim ersten TTS-PCM-Chunk. Damit IST Spotify ueber unseren Focus
gepaused, statt nur implizit. Der spaetere release beim Pcm-Playback-
Finished ist dann das normale 'Owner ist fertig'-Pattern das Spotify
zuverlaessig zum Resume triggert.

Idempotenz: requestDuck released vorher den vorigen Focus (in Kotlin),
also harmlos wenn wake-word-acquireConversationFocus eh schon requestDuck
gerufen hat. Plus _cancelDeferredFocusRelease vorne, damit kein noch
pendender 800ms-Timer mitten in der TTS Spotify falsch resumed.

Stefan testet im Auto und auf dem Tisch — beide Spotify-Versionen
sollten mit explizitem Focus-Owner-Wechsel sauber pausieren+resumen.
2026-05-31 23:51:43 +02:00
duffyduck ba26fa5880 feat(voice): ARIA produziert TTS-sprechbare Variante via <voice>-Tag
Stefan-Beobachtung: Wetterbericht klang scheisse, weil 'kt'/'°C'/Komma-
Zahlen literal vorgelesen wurden. Der clean_text_for_tts-Regex kennt nur
Markdown + ein paar Uhrzeit-Patterns — Einheiten ausschreiben war never
on the table mit Regex (waechst sonst zur Mammut-Tabelle).

Loesung: hybrid. ARIA selbst macht die semantisch korrekte TTS-Variante,
Regex bleibt als Safety-Net.

bridge/aria_bridge.py:
  - neue Helper strip_voice_tag_for_display(text)
  - chat-broadcast (1188) und chat_backup-Persist (1157) strippen den Tag
    BEVOR Output. ARIA's `<voice>...</voice>` lebt nur transient in `text`
    bis clean_text_for_tts ihn fuer TTS extrahiert.
  - clean_text_for_tts unveraendert (kennt den Tag schon seit Phase 1).

aria-brain/seed_rules.py:
  - neue seed-rule voice/tts-voice-tag mit klarer Trigger-Liste
    (Einheiten, Komma-Zahlen, Uhrzeiten, Statusberichte, lange IDs/Codes)
  - klare Anti-Trigger (kurze 'OK'/'mache ich'-Antworten — kein doppelter
    Text fuer Trivia)
  - drei konkrete Beispiele (Wetter, Uhrzeit, Server-Status, Music-Now-Playing)

Output-Format: ARIA schreibt erst Chat-Display-Variante (mit Markdown OK),
haengt dann an EINER neuen Zeile den <voice>-Block an. Tag wird automatisch
gestrippt fuer App-Anzeige + Chat-Backup. f5tts kriegt nur den voice-Inhalt.

Beide Welten happy: Stefan sieht hervorgehobenes Markdown in der Bubble,
hoert sprechbar formulierten Text aus dem Lautsprecher. Keine wachsende
Regex-Tabelle mehr.

Deploy: brain rebuild + restart, bridge restart.
2026-05-31 01:20:17 +02:00
duffyduck 027ba2896d fix(brain): dritter Tool-Result-Truncation-Punkt — agent.py:947 von 8KB auf 50KB
Im Stefan-Test (31.05.2026) hat ARIA das spotify-Skill korrekt mit
_all=true aufgerufen, der Skill paginierte sauber alle 90 Playlists
in 34 KB compact JSON. Aber: ARIA's Thinking sagte 'tool result was
cut off'. Disk-Log + Skill-Return waren beide OK. agent.py:1160 cap
ist 50 KB (passt). Aber:

agent.py:947 cap'd tool_result[:8000] beim Append in die Proxy-
Conversation. DRITTER Truncation-Punkt fuer denselben Output, fuer
Claude effectiv abgeschnitten — Skill liefert 34 KB, ARIA sieht 8 KB.

Auf 50 KB hochgesetzt (konsistent mit dem anderen Cap in derselben
Datei). Tests + nochmal restart noetig.

Lerneffekt: bei Stdout-Caps suche IMMER alle Truncation-Punkte. War
jetzt der dritte den ich heute gefunden hab. ;-)
2026-05-31 01:03:20 +02:00
duffyduck 86f20d3b64 clude config 2026-05-31 00:19:54 +02:00
duffyduck 78211f09ce feat(brain): Listen-API-Pagination strukturell loesen + seed-rule
Stefan-Reproduktion vom 31.05.2026: bei 'Such Playlist Prodigy raus'
hat ARIA die Spotify-Pagination drei Mal hintereinander laufen lassen,
jedes Mal eine andere Playlist-ID gefunden, am Ende falsche abgespielt.
Spotify sortiert /v1/me/playlists nach recently-played — die Reihen-
folge aendert sich zwischen Calls wenn parallel was laeuft, also
liefern aufeinanderfolgende paginierte Runs inkonsistente Snapshots.

Loesungen:

1. **spotify-Skill _all=true** (via skill_update angewendet, lebt nur
   in /data/skills/spotify/ im Container, nicht in git): Skill prueft
   _all=true im URL-Query, paginiert dann intern ueber Spotifys
   next-Field bis MAX_PAGES (20) oder fertig. Liefert konsolidiertes
   JSON {items, total, fetched_count, fetched_pages}. EIN Tool-Call,
   konsistenter Snapshot.

2. **skills.py: Stdout-Truncation entkoppeln**. Vorher: 8000-char-Cap
   sowohl fuer Disk-Log als auch fuer Return-Value an Agent.
   Konsequenz: _all=true Output (50 KB JSON) wurde fuer ARIA auf 8 KB
   gekuerzt, sie sah nur die ersten ~20 Playlists. Jetzt:
     - Disk-Log: weiterhin 8 KB pro stdout (Disk-Schoner)
     - Return-Value: ungekuerzt, agent.py macht 50 KB downstream-Cap
   Skills.py:687 — record-Dict aufgesplittet in log_record + record.

3. **seed_rule list-api-pagination-snapshot**: dokumentiert das
   Pattern fuer ARIA — bei Pagination-Resultaten IMMER vollstaendig
   laden bevor Entscheidung; _all=true bevorzugen wo verfuegbar;
   bei inkonsistenten Match-Resultaten ehrlich nachfragen statt
   raten. Mit konkreter Antipattern-Sammlung aus Stefans Test.

Deployment: brain restart noetig damit (2) und (3) greifen. Skill-
Code (1) ist schon via PATCH /skills/spotify aktiv.
2026-05-31 00:14:06 +02:00
duffyduck b2edee9adb release: bump version to 0.1.8.8 2026-05-30 23:32:27 +02:00
duffyduck bb13477ef9 fix(wake): Race zwischen endConversation und stopBargeListening killt
Wake-Word-Listener nach jeder Konversation

Aus dem Log diagnostiziert: zwei onPlaybackFinished-Listener feuern
direkt hintereinander wenn TTS endet:
  1. mein neuer Listener (Background): endConversation()
     → state=armed, OpenWakeWord.start() (idempotent)
  2. existierender Listener:           stopBargeListening()
     → bargeListening=true → OpenWakeWord.stop()  ← killt re-armed Listener

State zeigte 'armed' (UI: Ohr-Icon ausgefuellt, sieht aktiv aus), aber
das Native-Modul war gestoppt → Stefan's "Computer" verpufft.

Fix: endConversation setzt bargeListening=false BEVOR Native gerufen
wird. stopBargeListening checkt das Flag oben:
  async stopBargeListening() { if (!this.bargeListening) return; ... }
→ wird zum No-Op wenn endConversation schon gelaufen ist.

Bonus: OpenWakeWord.start() darf jetzt auch gerufen werden wenn der
Listener via barge-listening schon lief — Kotlin checkt running.get()
und resolved idempotent. Sicherer als state-vorher-Check.
2026-05-30 23:31:25 +02:00
duffyduck 710e7c88d8 release: bump version to 0.1.8.7 2026-05-30 23:23:52 +02:00
duffyduck b6ee5552f0 fix(app): Dateimanager Einzel-Download landet jetzt im Downloads-Ordner
Bug: '⬇ Download' im Dateimanager schickte file_request raus, aber kein
SettingsScreen-Handler nahm das file_response auf. ChatScreen fing es
zwar global ab, versuchte aber nur Chat-Bubble-Attachments zu
patchen — kein Match, also passierte sichtbar nichts.

Fix: Handler in SettingsScreen fuer file_response mit requestId-Praefix
'single-' (aus bulkDownload-1-Datei-Pfad). Schreibt nach
RNFS.DownloadDirectoryPath, mit Suffix-Inkrement bei Namens-Konflikt
damit nichts ueberschrieben wird.

Multi-Datei-Download (ZIP) lief schon ueber file_zip_response,
unangetastet.
2026-05-30 23:22:44 +02:00
duffyduck 570eb031e0 release: bump version to 0.1.8.6 2026-05-30 23:20:01 +02:00
duffyduck e9615d987e fix(audio): playbackFinished-Listener feuern erst wenn AudioTrack wirklich durch ist
Race-Condition entdeckt im Log: nach jeder ARIA-Antwort lief
endConversation 5s nach TTS-Start (= "letzter Chunk eingetroffen"),
nicht wenn der AudioTrack-Hardware-Buffer wirklich am Ende war. ARIA
sprach also noch hoerbar, waehrend OpenWakeWord schon re-armte.

Folge: ARIAs eigene Stimme ging direkt nach AudioRecord.startRecording
ins Mikro. Die OpenWakeWord-Sessions von AudioRecord und AudioTrack
sind verschieden → AcousticEchoCanceler kann den Output nicht
subtrahieren (kein gemeinsamer Reference-Stream). Threshold +
Patience-State der Wake-Word-Inferenz wird durch ARIAs konstante
Audio-Eingabe verwirrt, der naechste echte "Computer"-Trigger geht
unter.

Fix: Listener-Fire aus handlePcmChunk(isFinal=true) raus, dafuer in
den schon existierenden PcmPlaybackFinished-Native-Event-Handler
rein. Die Kotlin-Seite emittiert das Event aus dem Writer-Thread-
finally-Block — also genau dann wenn AudioTrack alle Samples
durchgeschrieben hat.

Side-Effect: UI-Konsumenten von onPlaybackFinished sehen den
"finished"-State jetzt 1-2s spaeter (= ehrlicher zur Realitaet,
ist eigentlich eine UX-Verbesserung).
2026-05-30 23:18:53 +02:00
duffyduck 5e95eacd11 release: bump version to 0.1.8.5 2026-05-30 23:11:16 +02:00
duffyduck ece08f0f2f debug(wake): RVS-Log in endConversation — sichtbar machen ob re-arm greift
Stefan beobachtet dass Wake-Word nach Conversation manchmal nicht
re-armt. endConversation hatte bisher kein RVS-Logging — wir waren
beim Diagnose blind.

Loggt jetzt:
  - 'endConversation called but state=X → noop' (state-Mismatch)
  - 'endConversation called, calling OpenWakeWord.start()' (Eintritt)
  - 'OpenWakeWord.start() OK → state=armed' (Erfolg)
  - 'OpenWakeWord.start() FAIL: ... → state=off' (Native-Fehler)
  - 'fallback: nativeReady=false → state=off' (kein Native-Modul)

Damit sehen wir im naechsten Test welcher Pfad gegriffen hat und ob
das Native-Modul ueberhaupt aufgerufen wurde.
2026-05-30 23:09:11 +02:00
duffyduck 31fd0d7f7a release: bump version to 0.1.8.4 2026-05-30 23:02:41 +02:00
duffyduck 263835ad74 fix(wake): Conversation-Window nur im Foreground, Background → direkt re-armen
Symptom: Wake-Word laeuscht nach erfolgreicher Konversation im
Hintergrund nicht wieder — erst beim App-Vorholen wird's wieder
armed. Grund: nach TTS-Ende laeuft wakeWordService.resume() in
einen setTimeout(800ms) der im Doze stark verzoegert wird. Der
verspaetete Timer findet dann delay > 2800 und ruft endConversation
(re-arm) — aber eben erst beim App-Resume.

Fix: in onPlaybackFinished AppState pruefen:
  active     → resume() wie bisher (Multi-Turn-Conversation-Window)
  background → endConversation() direkt — kein setTimeout, native
               OpenWakeWord.start() greift sofort.

Begruendung fuer das Verhalten:
- Foreground: User ist aktiv, Multi-Turn-Dialog ohne erneutes
  "Computer"-Sagen ist nuetzlich.
- Background: User nutzt das Handy anderweitig, automatisches Mikro-
  Oeffnen ist nicht erwartet und droht durch Doze-Verzoegerung in
  ein Phantom-Trigger-Mismatch zu kippen. Direkt re-armen ist
  robust + erwartungskonform.

Eng verwandt mit dem 0.1.7.0-Fix (kein setTimeout zwischen
wake.detect und Callback) — selbes Doze-Throttling-Pattern, andere
Stelle in der Pipeline.
2026-05-30 23:01:12 +02:00
duffyduck ab7e9801ee release: bump version to 0.1.8.3 2026-05-30 22:33:13 +02:00
duffyduck 3d001a1d03 feat(app): manueller Aufnahme-Knopf nutzt jetzt auch Streaming-STT
VoiceButton rewrite — dB/VAD-Pfad endgueltig raus. Knopf ist jetzt nur
noch UI-Trigger:
  - onTapStart   (ChatScreen baut Bubble + startStreamingRecording)
  - onTapStop    (ChatScreen ruft stopStreamingRecording)
  - audioService.onStateChange treibt die Animation (statt internem
    isRecording-Flag)
  - onSilenceDetected-Subscription weg

ChatScreen:
  - handleVoiceRecording (Legacy) → handleVoiceButtonStart +
    handleVoiceButtonStop
  - Bubble wird beim Tap SOFORT gebaut (vorher: erst nach Stop), Text
    landet via audioRequestId-Match im chat-Handler-Update-Pfad
  - noSpeechTimeoutMs=0 (manueller Modus, User kontrolliert via Tap),
    hardCapMs=300_000 (5 Minuten Notbremse)
  - Wake-Word-conversing + manueller Stop = endConversation (User
    will nicht in Multi-Turn-Modus)
  - RecordingResult-Import entfaellt (nicht mehr genutzt)

Damit ist die komplette App-seitige Aufnahme auf Streaming + ML-
Endpointer. Der ganze dB/VAD-Apparat (vadEnabled, vadBaselineSamples,
loadVadSilenceDbOverride, vadTimer, noSpeechTimer, etc.) ist jetzt
nur noch Dead-Code — wird in einem Folge-Commit gemeinsam mit dem
zugehoerigen Settings-Slider abgeraeumt.
2026-05-30 22:31:26 +02:00
duffyduck 91760dd2e1 release: bump version to 0.1.8.2 2026-05-30 22:24:28 +02:00
duffyduck 3c2e537420 fix(wake): kein Conversation-Window-Resume wenn JS-Thread verspaetet aufwacht
Symptom: User sagt "Naechstes Lied bitte", ARIA spielt Track, Display
geht aus, User holt 10s spaeter die App vor und sieht "Aufnahme laeuft"
— als haette er Wake-Word gesagt. Klassisches Doze-Throttling: nach
TTS-Ende schedulet resume() einen setTimeout(800ms) der den Conversation-
Window-Callback feuert. Im Hintergrund parkt der JS-Thread, der Timer
feuert erst beim App-Resume — gefuehlt ein Phantom-Trigger.

Fix: scheduledAt-Timestamp messen, Delay nach dem setTimeout pruefen.
Wenn der Timer >2.8s ueberfaellig ist (Schwelle = 800ms + 2000ms
Toleranz), JS war im Background → endConversation statt Mikro-oeffnen.

Wenn der User wirklich nachfragen will sagt er einfach nochmal "Computer".
2026-05-30 22:23:13 +02:00
duffyduck 97b6ea1b3e release: bump version to 0.1.8.1 2026-05-30 22:14:36 +02:00
duffyduck 94ee0455a2 fix(rvs): Streaming-STT-Message-Types whitelisten
Die ALLOWED_TYPES-Whitelist im RVS-Hub droppte stt_stream_start /
stt_audio_chunk / stt_stream_end / stt_partial / stt_endpoint /
stt_stream_done silent — App schickt, niemand kriegt. Das hat
Phase 1+2 komplett tot gemacht obwohl App + Whisper-Bridge
korrekt deployed waren.

Sechs neue Types eingetragen, dann fluppt's.
2026-05-30 22:13:31 +02:00
duffyduck 0bf6d49432 fix(app): UI-Fallback wenn Whisper-Bridge nicht antwortet
streamEndpointFired-Latch + neue _fireEndpoint(ev)-Methode konsolidieren
die drei Pfade die den Endpoint-Listener feuern (RVS-stt_endpoint, cancel,
neuer Fallback). Listener feuert pro Session-Cycle maximal einmal.

stopStreamingRecording bekommt einen 3-Sekunden-Watchdog: kommt in dem
Fenster keine echte stt_endpoint-Antwort der Bridge, feuert der
Listener mit text='' (reason=stop:...:no-response) damit ChatScreen
die "wird verarbeitet"-Bubble unstickt + endConversation aufruft.

Greift praktisch in zwei Faellen:
  - Whisper-Bridge laeuft alte/keine Streaming-Version (Stefan Gamebox-
    Restart vergessen) → wir bleiben sonst bis zur 60s-Hardcap haengen
  - User-initiated Stop + Whisper langsam/crashed
2026-05-30 22:09:02 +02:00
duffyduck 493cba36a2 feat(diagnostic): RVS-Debug-Logs fuer Whisper- und F5TTS-Bridge
Stefan's Gamebox ist Windows (kein SSH-Zugriff), und in Zukunft
koennten whisper/f5tts auf separaten Hosts laufen. Wir brauchen
deshalb einen Logging-Pfad ueber RVS — gleicher Mechanismus wie
fuer die App (reportAppDebug).

Beide Bridges senden jetzt app_log-Messages mit platform="whisper"
bzw. "f5tts". aria-bridge schreibt sie in /shared/logs/app.log
(unverändert), Live-Logs-Tab + Diagnostic /api/app-log lesen mit.

Toggle via aria-bridge config:
  whisperDebugLog: bool   — default OFF (aktuell aber ON in
                            whisper-bridge weil wir Phase-1/2-
                            Pipeline einfahren)
  f5ttsDebugLog:   bool   — default OFF

Beide werden in voice_config.json persistiert + nach RVS-Connect
rebroadcastet, damit Toggle Container-Restart ueberlebt.

Whisper-Bridge logt aktuell:
  boot                  → Streaming-Mode-Marker (sehen wir damit ob
                          neue Version aktiv ist)
  stream.start          → stt_stream_start angekommen
  stream.chunk          → alle 25 Chunks (=5s Audio) einer
  stream.chunk.reject   → Chunk fuer unbekannte Session
  stream.partial        → Whisper hat neuen Text erkannt
  stream.final          → Endpoint detected, finaler Text raus
  stream.end            → stt_stream_end angekommen
  config                → Toggle umgeschaltet

F5TTS-Helper ist da (gleicher Pattern), Logging-Punkte kommen
spaeter wenn wir ein konkretes TTS-Problem zu debuggen haben.
2026-05-30 22:00:55 +02:00
duffyduck a68827fb38 fix(updater): parseInt(number) -> Number() — fileSize.size ist schon number 2026-05-30 21:45:17 +02:00
duffyduck 11ca316e4e release: bump version to 0.1.8.0 2026-05-30 21:42:58 +02:00
duffyduck be1d2e950a feat(app): Streaming-STT-Pipeline — Phase 1+2 verdrahtet
audio.ts:
  - neue Methoden startStreamingRecording / stopStreamingRecording /
    cancelStreamingRecording mit PcmStreamRecorder als AudioRecord-Source
  - permanenter RVS-Listener fuer stt_partial / stt_endpoint / stt_stream_done,
    Filterung ueber streamRequestId-Match
  - Callbacks onSttEndpoint(SttEndpointEvent) + onSttPartial(text)
  - No-Speech-Watchdog + App-seitiger Hard-Cap (+2s Toleranz gegen Bridge)
  - cancelStreamingRecording feuert onSttEndpoint mit text='' damit
    ChatScreen den No-Speech-Fall behandeln kann (wie frueher
    onSilenceDetected -> stopRecording() -> null)
  - Legacy startRecording / stopRecording / onSilenceDetected unangetastet
    -- VoiceButton (manuelle Aufnahme) nutzt das weiterhin

ChatScreen.tsx:
  - Wake-Callback: startRecording -> startStreamingRecording
  - Bubble wird sofort gebaut, audioRequestId landet via
    stt_endpoint -> chat(sender=stt) im chat-Handler-Update-Pfad wie bisher
  - onSilenceDetected entfernt, ersetzt durch onSttEndpoint:
      text != '' -> log, aria-bridge triggert Brain selbst (Phase-2-Shortcut)
      text == '' -> endConversation (No-Speech-Fall)
  - Barge-In via Wake-Word: ebenfalls auf Streaming umgestellt
  - AppState-resume + toggleWakeWord-off pruefen jetzt isStreamingRecording()
    und nutzen passenden Cancel

Damit: kein dB/VAD mehr im Hot-Path. Whisper hoert auf semantische
Stille (kein neuer Text), Brain bekommt den Text direkt von aria-bridge,
Audio-Roundtrip App->aria->whisper->aria->App entfaellt komplett.
2026-05-30 21:42:02 +02:00
duffyduck 199297a3a1 feat(android): natives PcmStreamRecorder-Modul — 16 kHz mono s16le → JS-Events
Neues Native-Modul fuer die Streaming-STT-Pipeline:

  PcmStreamRecorder.start()  — oeffnet AudioRecord 16 kHz mono PCM,
                                VOICE_COMMUNICATION-Source mit AEC/NS,
                                PARTIAL_WAKE_LOCK gegen Doze
  PcmStreamRecorder.stop()   — sauber schliessen
  Event "PcmStreamChunk"     — {pcm: base64-s16le, seq, ts} alle 200ms
  Event "PcmStreamError"     — bei Capture-Crash

200ms-Chunks: gross genug fuer geringen RVS-Overhead, klein genug fuer
granulares Endpointing in der Whisper-Bridge.

Mic-Ownership: darf NICHT parallel zu OpenWakeWord laufen — beide
wollen AudioRecord. Coordination liegt bei audio.ts (stop OWW vor
start, start OWW nach stop), genau wie's bisher mit react-native-
audio-recorder-player gemacht wurde.
2026-05-30 21:33:18 +02:00
duffyduck e99bf0b032 feat(bridge): stt_endpoint-Handler — Phase 2 Brain-Shortcut
Empfaengt das stt_endpoint-Event der Streaming-Whisper-Bridge und
uebernimmt den Pfad den sonst _process_app_audio NACH dem STT-Schritt
hat: broadcastet chat(sender=stt) fuer die App-UI-Bubble, baut den
Core-Text und ruft send_to_core(). Damit faellt der Audio-Roundtrip
App→aria→whisper→aria komplett weg — die App schickt nur noch
PCM-Chunks direkt an whisper-bridge, whisper meldet Endpoint, aria
forwarded sofort an Brain.

Echos voice/speed/interrupted/location aus dem App-Payload werden
respektiert wie beim Legacy 'audio'-Event. clean_text_for_tts +
ttsText-Embedding bleiben unveraendert da der TTS-Pfad ueber das
bestehende send_to_core laeuft.

Idempotenz via audioRequestId als client_msg_id — falls die App den
Stream durch einen Reconnect-Race nochmal triggern sollte.

source-Tag fuer den Brain-Log: "app-voice-stream" statt "app-voice"
damit man im Brain-Log sehen kann ob via Legacy- oder Stream-Pfad.
2026-05-30 21:31:29 +02:00
duffyduck 41999c2304 feat(whisper): Streaming-Modus mit ML-Endpointer — Phase 1
Neue RVS-Messages auf der Whisper-Bridge:

  stt_stream_start  {requestId, audioRequestId, language?, model?,
                     endpointMs?=1500, hardCapMs?=60000, voice, speed,
                     interrupted, location, sampleRate?=16000}
  stt_audio_chunk   {requestId, pcm: base64-s16le, seq}
  stt_stream_end    {requestId, reason}
  stt_partial       (Bridge→App, alle ~700ms, fuer Live-UI-Feedback)
  stt_endpoint      (Bridge→App+aria-bridge, finaler Text + alle Echos)
  stt_stream_done   (Bridge→App, signalisiert Session-Ende)

Endpointer-Logik:
  - alle 700ms transkribiert die Bridge den Ringbuffer (beam_size=1, schnell)
  - waechst der Transkript-String → Stagnation-Timer reset
  - waechst er nicht → bei endpointMs ohne Wachstum: finalisiert
  - bei hardCapMs (60s) sowieso finalisiert egal ob stagnierend
  - Final-Transcribe nochmal mit beam_size=5 fuer Qualitaet
  - stt_endpoint enthaelt voice/speed/interrupted/location echos,
    damit aria-bridge in Phase 2 direkt an Brain weiterleiten kann

Legacy stt_request (One-Shot mit base64-mp4/wav) bleibt unveraendert
als Fallback.

Default-Parameter (alle vom App-Payload uebersteuerbar):
  STREAM_TRANSCRIBE_INTERVAL_MS = 700    (Throttle)
  STREAM_DEFAULT_ENDPOINT_MS    = 1500   (Stille = kein neuer Text)
  STREAM_DEFAULT_HARD_CAP_MS    = 60000  (Schmerzgrenze)
  STREAM_MIN_AUDIO_MS           = 600    (erst transkribieren ab N Audio)
  STREAM_SESSION_TTL_S          = 120    (tote Sessions aufraeumen)

Ersetzt den dB/VAD-Stille-Trigger auf der App-Seite — Endpointer
hoert auf SEMANTISCHE Stille (kein neuer Text), nicht akustische.
Funktioniert im Auto / mit Musik im Hintergrund / in lauten
Umgebungen wo VAD versagt.
2026-05-30 21:29:51 +02:00
duffyduck 095c1e2d70 release: bump version to 0.1.7.0 2026-05-30 21:02:59 +02:00
duffyduck 0145179aca fix(wake): kein setTimeout zwischen wake.detect und Callback — JS-Timer im Doze unzuverlaessig
Bridge-Log-Analyse zeigte: setTimeout(200ms) in onWakeDetected feuert im
Hintergrund (Display aus) entweder gar nicht oder erst nach 8+ Sekunden,
auch mit aktivem PARTIAL_WAKE_LOCK + Foreground-Service. Hermes parkt den
JS-Thread sobald er idle ist und wartet auf Native-Wake-Events; die
Bridge-Queue fuer Timer kommt erst dran wenn irgendein Native-Event
(z.B. Audio-Sample) den Thread weckt.

Drei Wake-Events live mitgelesen:
  - Vordergrund:  Timer feuert +209ms (ok)
  - Hintergrund:  Timer feuert +8061ms (wake-callback verspaetet)
  - Hintergrund:  Timer feuert nie (>5 min, gong-Sound bleibt aus)

OpenWakeWord.stop() ist davor awaited → Mikro ist garantiert frei.
Der 200ms-Sicherheitsabstand war Belt-and-Suspenders, jetzt entbehrlich.
Callback wird direkt synchron gefeuert.
2026-05-30 21:00:45 +02:00
duffyduck c2475ffef6 release: bump version to 0.1.6.9 2026-05-30 20:46:55 +02:00
duffyduckandClaude Opus 4.7 98982fea2f feat(app): App-Logs live im Settings → Protokoll → Live Logs Tab anzeigen
Stefan: "wir haben live log + events tab in protokoll einstellungen, da
ist aber nie was drin".

Bisher hoerten Live Logs / Events nur auf RVS-Messages type='log'/'event'
von der Bridge — die Bridge schickt aktuell aber keine solchen Messages
zurueck zur App. Plus: reportAppDebug/Error ging nur an die Bridge in
/shared/logs/app.log, lokal in der App war nichts sichtbar.

Loesung: lokaler DeviceEventEmitter-Bus.

logger.ts:
- APP_LOG_EVENT Konstante exportiert
- reportAppError + reportAppDebug emittieren ZUSAETZLICH zum
  RVS-Send ein lokales DeviceEventEmitter-Event (errors immer,
  debug nur wenn Toggle AN)

SettingsScreen.tsx:
- DeviceEventEmitter.addListener auf APP_LOG_EVENT
- Mappt Log-Entries 1:1 in den 'logs'-State (max 200)
- Cleanup in useEffect-return

Damit sieht Stefan beim Debuggen (Debug-Toggle AN, Live-Logs-Tab
offen) live in der App was passiert — ohne curl gegen Bridge.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:44:42 +02:00
duffyduckandClaude Opus 4.7 356f8b3171 feat(app): Debug-Logs-an-Bridge Toggle (Settings → Protokoll, default aus)
Stefan: "haben wir einen Menupunkt logging? sonst muellen wir uns dicht
wenns funktioniert und wir das logging im moment nicht brauchen"

Stimmt. reportAppDebug() schickt aktuell IMMER an Bridge, auch wenn
gar nicht debuggt wird. Bei armed Wake-Word + Pipeline-Logs sind das
schnell ein Dutzend Eintraege pro Wake-Trigger.

Loesung: separater Settings-Toggle "Debug-Logs an Bridge" mit eigenem
AsyncStorage-Key (aria_debug_logs_to_bridge), Default AUS.

- logger.ts: _debugLogsToBridge flag + isDebugLogsToBridge() /
  setDebugLogsToBridge(). initLogger() laedt den Wert. reportAppDebug()
  prueft das Flag und schickt nur wenn AN.
- SettingsScreen: neuer Toggle direkt unter Verbose-Logging,
  orange (#FF9500) damit er als "Power-User-Option" erkennbar ist,
  mit Erklaerungs-Hinweis dass nur Info-Logs gefiltert werden,
  Crash-Reports (Errors via reportAppError) gehen weiterhin IMMER.

Workflow:
- Default-User: Toggle aus, kein Traffic, kein Disk-Schreiben
- Stefan beim Debuggen: Toggle an, testet die App, schaut Logs via
  curl /api/app-log?lines=N, schaltet wieder aus

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:41:40 +02:00
duffyduckandClaude Opus 4.7 b4115bb345 debug(wake): mehr Log-Punkte zwischen onWakeDetected-Trigger und Callback-Feuern
Stefan's Test zeigt: 'wake.detect keyword=computer state=armed' kommt
im Background durch (WakeLock greift!), aber 'wake.cb callback fired'
aus ChatScreen fehlt. Heisst: zwischen Detection und Callback-Feuern
geht's irgendwo verloren.

Mehr Logs:
- nach OpenWakeWord.stop(): 'native stop ok' oder 'native stop FAIL msg'
  → klaert ob async stop() haengt
- vor setTimeout: 'state→conversing, wakeCallbacks.length=N, scheduling'
  → klaert ob Liste leer ist (ChatScreen unmounted) und ob wir's
    schedulen
- im setTimeout: 'timeout fired, state=X, cbs=N'
  → klaert ob der Timer in 200ms tatsaechlich feuert (Doze-Throttle?)
- bei barge-path: 'barge path: cbs=N'

Damit sehen wir genau wo's klemmt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:38:14 +02:00
duffyduck 02cac99ef9 release: bump version to 0.1.6.8 2026-05-30 20:29:41 +02:00
duffyduck 2940ce0075 release: bump version to 0.1.6.7 2026-05-30 20:28:38 +02:00
duffyduckandClaude Opus 4.7 d78b668e31 feat(app): reportAppDebug — Live-Debug-Logs an Bridge ohne ADB
Stefan-Anforderung: Background-Wake-Word-Pipeline klappt noch nicht,
ADB nicht zur Hand → Debug via RVS-Log-Pipeline.

Logger:
- reportAppDebug(scope, message) analog zu reportAppError aber
  level=info, kein console.error, fuer Live-Diagnose

Strategische Log-Punkte:
- wakeword.ts: start() emits 'wake.start armed'
- wakeword.ts: onWakeDetected emits 'wake.detect state=X' beim
  Native-Trigger-Empfang
- ChatScreen.tsx wake-callback: 'wake.cb callback fired',
  'wake.cb startRecording=X', 'wake.cb gong played'
- backgroundAudio.ts: 'bg.start slot=X', 'bg.stop service stopped',
  'bg.start.fail msg' wenn Service nicht hochkommt

Abruf live via curl http://172.0.2.33:3001/api/app-log?lines=100

Damit kann Stefan nach APK-Build (mit allen Native-Fixes + Logger)
im Background-Test exakt sehen wo es klemmt:
- Kommt 'wake.detect' im Hintergrund an? (WakeLock-Frage)
- Kommt 'wake.cb callback fired'? (JS-Bridge-Frage)
- Geht 'bg.start slot=wake' durch? (Service-Start-Frage)

APK neu bauen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:27:29 +02:00
duffyduck a9115699db release: bump version to 0.1.6.6 2026-05-30 20:21:29 +02:00
duffyduckandClaude Opus 4.7 f2bfd4bbc6 feat(app): Background-GPS als opt-in Settings-Toggle
Stefan-Anforderung: GPS soll auch im Hintergrund liefern (Auto-Szenarien,
Handy-Tasche), aber NUR fuer Power-User die das bewusst aktivieren.
Mama-Tauglichkeit bleibt erhalten — Default AUS, keine Surprise-Permission.

Aenderungen:

AndroidManifest:
- ACCESS_BACKGROUND_LOCATION Permission
- FOREGROUND_SERVICE_LOCATION Permission
- AriaPlaybackService foregroundServiceType erweitert um |location
  (vorher: mediaPlayback|microphone)

backgroundAudio.ts:
- Neuer Slot 'location' zwischen 'wake' und 'background' in der
  Prioritaeten-Liste. Notification zeigt entsprechend.

gpsTracking.ts:
- isBackgroundGpsEnabled() / setBackgroundGpsEnabled() AsyncStorage-Helper
- ensureBackgroundLocationPermission() pruefte ACCESS_BACKGROUND_LOCATION
  und oeffnet Android-Settings wenn fehlend (auf Android 10+ kann das
  NICHT ueber den normalen Permission-Dialog angefordert werden)
- start(): wenn BG-GPS enabled, acquireBackgroundAudio('location') →
  Foreground-Service hochziehen mit type=location
- stop(): releaseBackgroundAudio('location')

SettingsScreen.tsx:
- Neuer Toggle "GPS auch im Hintergrund" direkt unter dem
  GPS-Tracking-Toggle, rot (#FF3B30) statt orange weil's eine stark
  privacy-relevante Einstellung ist
- Erklaerungs-Text zu Android-Settings + Akku-Verbrauch
- Beim Aktivieren: Permission-Check, ggf. Android-Settings oeffnen
- Wenn Tracking bereits laeuft: neustart damit location-Slot greift

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:19:16 +02:00
duffyduck b182ef5ed5 release: bump version to 0.1.6.5 2026-05-30 20:12:39 +02:00
duffyduckandClaude Opus 4.7 9818dc1867 fix(app): Spotify resumed wieder nach TTS — nudgeMediaResume mit TRANSIENT
Stefan-Bug-Report: ARIA liest Nachricht vor, Spotify pausiert korrekt,
ARIA spricht durch — aber Spotify spielt danach NICHT automatisch
weiter. Sollte mit GAIN_TRANSIENT auto-resumen, tut es aber bei
manchen Spotify-Versionen/Geraeten nicht zuverlaessig.

Hintergrund: alte kickReleaseMedia() mit AUDIOFOCUS_GAIN (permanent)
war zu aggressiv (Spotify interpretierte als "user stoppte" =
Auto-Resume kaputt). Wurde entfernt. Jetzt ist das Pendel andersrum
zu weit: ohne Nudge keine Resume.

Sanfter Mittelweg: nudgeMediaResume() mit GAIN_TRANSIENT statt
GAIN-permanent. 100ms hold, abandon. Spotify bekommt Focus-Wechsel-
Hint ohne "user stopped"-Effekt.

audio.ts: nach AudioFocus.release() 50ms warten, dann nudgeMediaResume.
AudioFocusModule.kt: neue Methode + alte kickReleaseMedia bleibt mit
⚠️-Markierung fuer andere Use-Cases.

APK neu bauen erforderlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:09:55 +02:00
duffyduckandClaude Opus 4.7 543ad3c46d fix(app): WakeLock auch im AriaPlaybackService — Pipeline-weiter Schutz
Stefan-Ergaenzung: nach Wake-Word muss Aufnahme, Senden und ARIA-
Antwort + TTS auch im Hintergrund klappen, und danach soll das ganze
wieder von vorne als Konversations-Schleife laufen.

Vorher hielt nur OpenWakeWordModule einen WakeLock (commit 408d20a).
Sobald Wake-Word erkannt wurde, ruft die JS-Seite OpenWakeWord.stop()
fuer das Mic-Handover an audioService.startRecording() — und der
WakeLock wurde released. Mid-Aufnahme konnte die CPU dann in Doze
gehen, Audio-Chunks erreichten die JS-Bridge nicht zuverlaessig.

Fix: AriaPlaybackService haelt selbst einen PARTIAL_WAKE_LOCK,
solange der Foreground-Service aktiv ist. acquireBackgroundAudio()
in der JS-Seite haelt den Service ueber alle Pipeline-Schritte
(wake → rec → tts → wake) durchgehend — damit ist der WakeLock
ueber die ganze Konversations-Schleife durchgehend aktiv.

Doppelter Schutz (WakeLock auch im OpenWakeWordModule) bleibt drin
als defense in depth — beide haben setReferenceCounted(false), also
keine doppel-buchhaltung, einfach robuster gegen einzeln-failende
acquires.

APK neu bauen erforderlich (native Kotlin-Aenderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:04:55 +02:00
duffyduckandClaude Opus 4.7 408d20a087 fix(app): PARTIAL_WAKE_LOCK fuer Wake-Word — JS-Bridge bleibt im Hintergrund aktiv
Stefan-Bug-Report: Wake-Word wird im Hintergrund erkannt (Spotify
pausiert sofort), aber der Gong + Aufnahme-Start kommen erst wenn die
App in den Vordergrund geholt wird. Akku-Optimierung war bereits
deaktiviert ("Hintergrund aktiv").

Ursache: Foreground-Service haelt den App-Prozess am Leben + erlaubt
mic-Zugriff via foregroundServiceType=microphone. Aber: ohne expliziten
WakeLock kann die CPU im Doze-Mode (Display aus / Telefon idle) die
Auslieferung von DeviceEvents an die React-Native-JS-Bridge pausieren.
Folge: Native erkennt Wake-Word, ruft emit("WakeWordDetected"), aber
das Event queued sich nur — der JS-Listener (onWakeDetected → start-
Recording + playWakeReadySound) feuert erst beim naechsten JS-Tick,
und der kommt erst beim App-Resume.

Fix:
- AndroidManifest: WAKE_LOCK Permission hinzu (kein User-Prompt noetig,
  ist eine "normal" Permission).
- OpenWakeWordModule.kt: PowerManager.PARTIAL_WAKE_LOCK in start()
  acquired (8h Cap als Sicherheit), in stop() + dispose() released.
  Lock-Tag "AriaCockpit:WakeWordRecord" damit der in adb shell dumpsys
  power sichtbar ist.

Wirkung: solange Wake-Word "armed" ist, bleibt die CPU wach und die
JS-Bridge verarbeitet die Detection-Events live — Gong, Mic-Start,
ARIA-Antwort kommen ohne Foreground-Resume durch.

APK muss neu gebaut werden (native Kotlin-Aenderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:03:08 +02:00
duffyduck 0756baa2a0 release: bump version to 0.1.6.4 2026-05-30 19:31:08 +02:00
duffyduckandClaude Opus 4.7 27c9b1af96 chore(compose): aria-shared von named Volume zu Bind-Mount (./aria-shared/)
Stefan-Wunsch: Daten aus dem Docker-managed Volume in ein lokales
Verzeichnis verschieben damit sie direkt inspizierbar / per
File-Manager zugaenglich sind statt unter
/var/lib/docker/volumes/aria-agent_aria-shared/_data/ versteckt.

Aenderungen:
- docker-compose.yml: 4 Mounts (proxy/brain/bridge/diagnostic) und die
  named-Volume-Definition aria-shared umgestellt auf bind-mount
  ./aria-shared:/shared
- .gitignore: aria-shared/ ausgeschlossen (enthaelt private User-Daten,
  Voice-Samples, OAuth-Tokens, chat_backup.jsonl — gehoert nicht ins Git)

Migration auf der VM (manuell, einmalig):
    cd /root/ARIA-AGENT
    docker compose down
    cp -a /var/lib/docker/volumes/aria-agent_aria-shared/_data/. aria-shared/
    git pull
    docker compose up -d
    docker volume rm aria-agent_aria-shared  # alt aufraeumen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:44 +02:00
duffyduckandClaude Opus 4.7 70f4ff480e fix(app): Mund-halten-Button stoppt ARIA jetzt sofort — AudioTrack flush vor stop
Stefan-Bug-Report: wenn ich in der App auf den Mund-halten-Button
klicke waehrend ARIA redet, stoppt sie nicht.

Ursache: stopInternal() rief nur AudioTrack.stop() + release(). Das
stoppt zwar den Track, aber der bereits in den Hardware-Buffer
geschriebene PCM-Audio (200-500ms je nach Geraet) spielt noch
hoerbar weiter. Fuer den User klang das so als wuerde der Button
nichts tun.

Fix in 2 Zeilen: AudioTrack.pause() + AudioTrack.flush() vor stop().
flush() verwirft den Hardware-Buffer-Inhalt, dadurch ist die
Wiedergabe wirklich sofort still. pause() davor weil flush() laut
Android-Docs nur in non-playing state safe ist.

Native module ist kompiliert in app/build/tmp/kotlin-classes — APK
muss neu gebaut werden damit der Fix greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:36:05 +02:00
duffyduckandClaude Opus 4.7 c23daf14e3 fix(xtts): Sticky-TLS-Fallback in whisper + f5tts Bridges — gleicher Bug wie damals App/Bridge
Stefan-Bug-Report: Diagnostic zeigt seit Tagen 'XTTS-Server: Nicht
verbunden (starte xtts/ auf dem Gaming-PC)' obwohl der Container
laeuft. Keine TTS-Ausgabe, keine STT-Eingabe.

Ursache: exakt der gleiche Sticky-TLS-Fallback-Bug den wir vor ein
paar Tagen bei aria-bridge (commit b5ca3cd) und Android-App (commit
ad87c80) gefixt hatten — die xtts/whisper- und xtts/f5tts-Bridges
sind aber separate Codebases auf der Gamebox und wurden uebersehen.

Mechanik:
1. RVS hatte mal kurzen TLS-Hick (z.B. Caddy-Restart oder Port-Wechsel
   443 → 444 vor Tagen)
2. Bridge versucht wss:// → fail → switch auf ws:// (use_tls = False)
3. Connect klappt jetzt nicht mehr (RVS-Port hatte sich geaendert)
4. Reconnect-Loop bleibt auf ws://, kommt NIE mehr auf wss zurueck
5. Container laeuft, RVS-Status 'nicht verbunden'

Fix: nach jedem Disconnect-Sleep `use_tls = RVS_TLS` und
`tls_fallback_tried = False` zuruecksetzen. Bei jedem Reconnect-
Cycle wird wss neu probiert; falls das wieder failt, switcht's
sauber auf ws fuer den naechsten Versuch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:30:09 +02:00
duffyduckandClaude Opus 4.7 ebfde4cd1f fix(brain): no-hallucinated-results geschaerft — Listen-Daten IMMER fetchen
Vorfall 30.05.2026: Stefan fragte 'was kommt als naechstes in der Queue'.
ARIA hat NICHT run_spotify mit /queue aufgerufen, sondern 'Africa von Toto'
aus dem Training-Wissen geraten und als Fakt verkauft. Stefan hat das
gemerkt, war sauer ('das geht mal gar nicht!'). Beim Eingestaendnis hat
ARIA dann auch noch einen Witz gemacht ('Faulheit sieht bei mir wie ein
Spotify-DJ aus 😅') — bei Vertrauensbruch ist das die falsche Reaktion.

Regel-Update:
- Liste konkreter Listen-/State-Daten die IMMER per Tool-Call gefetched
  werden muessen (Queue, Playlist, Wiedergabe-Status, Devices, Memories,
  Triggers, Skills, OAuth-Status, GPS, Bestellungen, Calendar, Mails …)
- 3 dokumentierte Antipatterns mit Datum (Set You Free, Africa, 403-
  raten) — erfahrungsbasiert wirkt staerker als abstrakt
- Neue Verhaltens-Regel beim Eingestaendnis: keinen Witz machen wenn
  Stefan angepisst ist. Ernsthaft Vertrauen reparieren, Humor spaeter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:20:06 +02:00
duffyduckandClaude Opus 4.7 5d3e3e5e8c fix(diagnostic): ZIP-Download abgewuergt — req.on(close) zu aggressiv
Bug-Report Stefan: Datei-Manager in der Android-App kann nichts mehr
herunterladen. Test gegen /api/files-download-zip lieferte 79 Bytes
ZIP (nur Header) statt der erwarteten 26 KB.

Ursache: req.on("close", () => zip.kill("SIGTERM")) sollte den
zip-Subprocess killen wenn der Client mid-stream abbricht. ABER:
req.on("close") feuert in Node.js auch SOFORT nachdem der Request-
Body fertig gelesen wurde — nicht erst bei echtem Client-Disconnect.
Folge: zip wird unmittelbar nach req.on("end") gekilled, hat nur
Zeit den Local-File-Header zu schreiben, kein File-Content, kein
Central-Directory.

Fix: statt req.on("close") nun res.on("close") + res.writableEnded-
Check. Das feuert nur wenn die Response wirklich vorzeitig abgebrochen
wird (Client weg / Netzwerk-Fehler), nicht wenn res.end() durch pipe
sauber durchgereicht wurde.

Chat-Bubble-Downloads (anderer Endpoint, /api/files-download mit
direktem fs.createReadStream statt zip-spawn) funktionierten weiter,
deshalb war der Bug bisher nicht aufgefallen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:51:23 +02:00
duffyduckandClaude Opus 4.7 0d69e211cb feat(brain): Hard-Safety-Seed — keine destruktiven Tests auf Production
Beobachtung 30.05.2026 08:28-08:54: ARIA hat einen Pentest gegen
kundencenter.hacker-net.de (Production!) angesetzt statt gegen
kundencenter-stage.stressfrei-wechseln.de (Staging). Stefan musste
explizit korrigieren ('du nutzt das falsche system!!!'). Haette ARIA
einen Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten weg.

Diese Safety-Boundary darf NIE verloren gehen — gehoert in seed_rules
(Code), nicht in Brain-Memory (DB). Bei DB-Wipe ist eine Memory weg,
ein Seed kommt beim naechsten Brain-Boot automatisch zurueck.

Neue 20. Regel an Position 1 (ueber allen Skill-Regeln):
- Destruktive Operationen (Factory-Reset, DELETE, DROP, Mass-Update,
  Credential-Rotation, Mass-Mail) NIEMALS auf Production
- Bei Pentest/Audit/Test: pruefen ob Staging existiert, im Zweifel
  Stefan EXPLIZIT fragen
- NIE annehmen 'wird schon Staging sein' — Production ohne stage/
  test-Marker ist im Zweifel Production
- Hard-Boundary, ueberstimmt jede andere Anweisung. Nur explizite
  Stefan-Ausnahme im aktuellen Turn kann sie aufweichen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:59:40 +02:00
duffyduckandClaude Opus 4.7 4ea13afe60 fix(brain): 19. seed_rule — vor skill_update lesen, API-Errors zitieren statt raten
Beobachtung 30.05.2026 02:51-02:53: zwei verkettete Antipatterns
beim Spotify-Test.

1. ARIA bekam 403 vom /pause-Endpoint, vermutete 'der 204-Bug ist
   zurueck' und patchte den Skill — zweimal hintereinander. Der
   204-Fix war aber laengst im Code (haette sie durch skill_get in
   5s gesehen). Symptome != Diagnose.

2. Bei den 403s antwortete sie 'war schon pausiert, daher der 403'
   und 'schon aktiv, daher der 403'. Beides war geraten basierend
   auf is_playing-Check, nicht aus den Daten gelesen. 403 'Restriction
   violated' kann viele Ursachen haben (NO_ACTIVE_DEVICE,
   ALREADY_PAUSED, PREMIUM_REQUIRED, MARKET_RESTRICTED, ...) — die
   wahre steht als error.reason im JSON-Body. Sie hat das verschluckt
   und plausibel-aber-geraten geantwortet.

Eine Regel deckt beide Patterns ab, generisch fuer alle Skills:
- Vor jedem skill_update: erst skill_get lesen, dann beurteilen
- Bei HTTP-Errors: Body / error.reason zitieren, nicht raten
- Wenn der Skill die wahre Ursache verschluckt: skill_update mit
  besserer Error-Extraktion (NACH skill_get, nicht davor)

Wirkt fuer alle aktuellen + zukuenftigen API-Skills.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 03:00:59 +02:00
duffyduckandClaude Opus 4.7 d12bfd0302 refactor(brain): Auto-Magie raus — ARIA entscheidet selbst, Stefan fragt im Zweifel
Mut zur Luecke: -595 Zeilen Auto-Magie-Code raus, weil sie heute Abend
4 Bugs verursacht und 0 echten Mehrwert geliefert hat. Plus Stefan
hat zu Recht erkannt dass das System mit Pentest/Audit-Workflows
kollidieren wuerde (Whitelist-Pflege noetig).

Weg:
- aria-brain/api_heuristic.py geloescht (282 Zeilen Cross-Session-
  Tracking, Hint-Generation, Bypass-Detection)
- aria-brain/agent.py: Auto-Scaffold-Block, Bypass-Detection-Block,
  _upsert_bypass_lesson-Methode (-146 Zeilen)
- aria-brain/main.py: /skills/can-bash-host Endpoint
- aria-brain/prompts.py: api_heuristic_section-Parameter
- docker-compose.yml: managed-settings-Copy aus proxy-Command
- proxy-patches/pre-tool-bash-block.js (PreToolUse-Hook)
- proxy-patches/managed-settings.json (claude-CLI Hook-Config)

Bleibt (kostet nichts, hilft):
- Alle 18 seed_rules (sind in DB, machen keine Last)
- skill_scaffold Tool (ARIA kann es manuell nutzen)
- Anti-Friedhof + snake_case + Safe-Name-Mapping (passive Validierung)
- Versionierung + Rollback (P4, hat sich bei PATH-Bug bewaehrt)
- 50k stdout Truncate-Fix

scaffold-reflex seed_rule umgeschrieben: kein 'SOFORT scaffold'-
Reflex mehr, stattdessen 4-Punkte-Heuristik (parametrisierbar?
wiederkehrend? exploratory? im Zweifel: Stefan fragen). Pentest-
Workflows bleiben damit ad-hoc Bash ohne false-positive
Skill-Vorschlaege.

Existierende auto-feedback-Memories in der DB bleiben — sind nuetzliche
Lehren, werden nicht mehr automatisch erweitert. Stefan kann sie via
Diagnostic-Gehirn-Tab loeschen wenn sie nerven.

Dank git ist alles rueckholbar. Wenn doch wieder Auto-Magie gewuenscht:
git revert auf 8d5991f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:47:32 +02:00
duffyduckandClaude Opus 4.7 8d5991f364 fix(brain): 18. seed_rule — Side-Effect-Tools nicht blind retry'en
Beobachtung 30.05.2026 02:22: Stefan bat 'vorheriges lied'. ARIA hat
POST /previous gemacht — Spotify gab 204 No Content zurueck (Erfolgs-
Antwort ohne Body), aber der alte Skill-Code warf JSON-Parse-Error
weil kein Body zum Parsen. ARIA interpretierte das als 'Skill kaputt',
patchte ihn UND fuehrte previous nochmal aus.

Folge: Stefan landete ZWEI Lieder zurueck statt eins. Aergerlich weil
unerwartete Zustandsaenderung.

Neue Regel adressiert das:
- Side-Effect-Tools (POST/PUT/DELETE, next/previous/play/pause, send-
  message etc.) sind NICHT idempotent — Retry verdoppelt den Effekt.
- Bei unklarem Result IMMER zuerst State pruefen (currently-playing,
  list-Endpoint etc.), dann beurteilen ob Wiederholung noetig.
- HTTP 204 No Content ist KEIN Fehler bei POST/PUT — typische Spotify-
  Antwort. Skill darf 204 NICHT als Parse-Error werten.
- GET-Calls / Search sind retry-safe, hier keine Sorge.

ARIAs zweiter Skill-Patch ist uebrigens technisch korrekt (ARG_-
Konvention zurueck, 204 handled, strukturierte Ausgabe fuer
currently-playing). Nur das doppelte Side-Effect war das Problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:26:17 +02:00
duffyduckandClaude Opus 4.7 7d16a0f3e5 fix(brain): 17. seed_rule — ARG_<NAME> ENV-Konvention NIEMALS aendern
Beobachtung 30.05.2026: ARIA hat beim skill_update des spotify-Skills
die ARG_-Konvention verloren. Statt os.environ.get('ARG_PATH', '')
hat sie os.environ.get('PATH', '') geschrieben. PATH ist aber die
reservierte Linux-Environment-Variable fuer den Executable-Suchpfad
(/usr/local/sbin:/usr/local/bin:...).

Folge: Skill las den System-PATH als URL-Pfad, rief
https://api.spotify.com/usr/local/sbin:/usr/local/bin:... → 404
zurueck. Stefan dachte Spotify sei kaputt. Rollback noetig
(Auto-Archive hat geholfen — alte Version war gluecklicherweise
noch da).

Neue Regel macht das explizit:
- ARG_<UPPER_NAME> ENV ist Pflicht-Konvention vom Skill-Runner
- Liste reservierter ENV-Namen die NICHT genommen werden duerfen:
  PATH, HOME, USER, SHELL, LANG, TERM, PWD, OLDPWD,
  BRAIN_INTERNAL_URL, SKILL_DIR, SHARED_UPLOADS, CFG_*
- Mit Praefix ARG_ keine Kollision moeglich

Plus skill_create Tool-Description um den gleichen Hinweis
ergaenzt: 'Args lesen via os.environ['ARG_<UPPER_NAME>'] — der
Praefix ARG_ ist Pflicht. NIEMALS direkt PATH/METHOD/BODY etc.
abrufen — das sind reservierte System-ENV (PATH = Executable-
Suchpfad, nicht Dein arg!).'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:17:01 +02:00
duffyduckandClaude Opus 4.7 0a859f637b fix(brain): 16. seed_rule — Skills sind erweiterbar, nicht heilig
Beobachtung 30.05.2026: Stefan bittet ARIA via skill_update den
spotify-Skill so anzupassen dass currently-playing strukturiert
ausgegeben wird (Track/Artist/Album/Device/Zeit). ARIA antwortet
mit Defensiv-Reflex: 'Der Skill ist nur ein OAuth2-Wrapper, ich
kann das nicht im Wrapper bauen — ich schlage einen zweiten Skill
spotify_now_playing vor'.

Quatsch. Skills sind beliebiger Python-Code. Ein
`if path.endswith('currently-playing'): pretty_output()` waere
trivial im Skill drin gewesen. Stefan haette das nicht selbst
erkennen muessen — genau dafuer ist ARIA da.

Neue Regel macht das explizit:
- skill_get + skill_update ist der Standard-Workflow fuer
  Skill-Anpassungen
- Skills duerfen if-Verzweigungen, json-Parsing, Output-Filterung,
  mehrere Endpoints in einem Skill etc.
- 'Kann ich nicht in den Wrapper bauen' ist Antipattern
- 'Ich schlage einen zweiten Skill vor' ohne erst skill_update
  zu pruefen ist Antipattern
- Stefan ist KEIN Python-Entwickler — er nennt das ZIEL, ARIA
  baut das WIE.

Plus skill_update Tool-Description um den gleichen Gedanken
ergaenzt: 'Skills sind ganz normaler Python-Code, du kannst sie
beliebig erweitern.'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:09:37 +02:00
duffyduckandClaude Opus 4.7 8c1476c2ca fix(brain): 15. seed_rule — Brain-Tools per XML-Tag, nicht als native Tool-Use
Beobachtung beim Hook-Deploy-Test (30.05.2026, 01:51-52): ARIA versucht
run_spotify zuerst als nativen Tool-Use → 'No such tool available'
weil claude-CLI nur seine eigenen Tools (Bash/Read/Write/etc.) kennt;
Brain-Tools sind als Prompt-Instruction injiziert.

Erst nach dem 'No such tool'-Fehler wechselt ARIA aufs XML-Tag-Format
<tool_call name="...">{...}</tool_call>, das der proxy parsed und ans
Brain weiterleitet. Dieser Lernzyklus pro Anfrage kostet ~30s.

Die Regel erklaert die Architektur (claude-CLI vs Proxy vs Brain) und
gibt das richtige Format vor — direkt XML-Tag, nicht native Tool-Use.

Beilaeufige Bestaetigung an Stefan: seed_rules.py ist System-Code, wird
bei jedem Brain-Lifespan-Start aufgespielt — frische DB nach Wipe wird
beim ersten Boot mit den 15 Regeln gesetzt, idempotent ueber
migration_key. Im Gegensatz zu brain-import/ (gitignored, manuelle
Migration via Diagnostic-Klick).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:56:53 +02:00
duffyduckandClaude Opus 4.7 7d8c411f5c feat(proxy): PreToolUse-Hook blockiert Bash-curl wenn Skill existiert
Variante A endlich umgesetzt: echter Hard-Block vor Bash-Ausfuehrung.
Anders als 14 seed_rules + Bypass-Lehre, die ARIA ignorieren kann,
ist das ein technisch erzwungener Reject auf claude-CLI-Ebene.

Komponenten:

1. aria-brain main.py: neuer Endpoint POST /skills/can-bash-host
   Bekommt {command}, parst https-URLs raus, prueft gegen aktive Skills
   (stem-match: 'spotify' im Hostname 'api.spotify.com'). Returnt
   {block, host, skill, safe_tool} wenn ein Skill den Host abdeckt.

2. proxy-patches/pre-tool-bash-block.js: Node-Script das vom claude-CLI
   als PreToolUse-Hook fuer das Bash-Tool aufgerufen wird. Liest Tool-
   Use-Payload via stdin, ruft Brain-Endpoint mit kurzem Timeout (3s),
   bei block=true → exit 2 mit Stderr-Message. claude-CLI gibt Stderr
   als tool_use_error an das LLM zurueck — echter Fehler, nicht
   ignorierbar.
   Fail-open bei Brain-Down / Timeout / JSON-Fehler: kein Lockout.

3. proxy-patches/managed-settings.json: claude-CLI Hook-Config mit
   PreToolUse-Matcher 'Bash' der das Node-Script ausfuehrt.
   /etc/claude-code/managed-settings.json hat Vorrang vor User-Settings
   und betrifft NICHT Stefans Host-~/.claude/settings.json.

4. docker-compose.yml: proxy-Command erweitert um
   `mkdir -p /etc/claude-code && cp managed-settings.json dorthin`
   damit beim Container-Start die Hook-Config aktiv ist.

Beobachtung die das motiviert: 14 seed_rules + Bypass-Lehre +
Auto-Scaffold + Safe-Names. ARIA hat trotzdem letzten Test mit 2
verschachtelten Bash-curls bedient statt run_spotify zu rufen
(content_len=73, tool_calls=0). Prompt-Engineering ausgereizt.

ARIA bekommt jetzt:
🚨 BASH GEGEN api.spotify.com BLOCKIERT.
Es existiert bereits ein Skill 'spotify' fuer diesen Host. ...
Konkret: nutze JETZT `run_spotify` mit den passenden Parametern
(method/path/body) statt curl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:49:56 +02:00
duffyduckandClaude Opus 4.7 fef2a32c50 fix(brain): Skill-stdout-Limit von 2000 auf 50000 — Track-Daten wurden abgeschnitten
DER eigentliche Bug warum ARIA Spotify-Tracks halluziniert hat. Lange
Diagnose-Session am 30.05.2026 zeigte: ARIA RUFT run_spotify echt auf
(im Brain-Log zu sehen als tool_calls=1 + skill liefert echte Daten).
Aber bevor das Ergebnis an Claude zurueckging, hat dieser Code:

    snippet = (res.get("stdout") or "")[:2000]

es auf 2000 Zeichen abgeschnitten. Spotify-JSON ist 5-15 KB —
"album":{"name":"..."} steht frueh drin (kommt durch), aber
"item":{"name":"..."} (Track-Name selbst) und alle Detail-Felder
liegen weiter hinten und wurden verworfen.

Folge: ARIA bekam nur den Anfang vom JSON inkl. Album-Name, hat dann
den bekanntesten Track aus dem Album geraten (Album "Loneliness" ->
Track "Loneliness"; Album "Sound Of Belgium" -> Track "House of
House"). Semi-Halluzination weil halbe Information.

Fix: 50000 Zeichen Limit fuer stdout (Claude verkraftet das locker,
hunderte KB Context). stderr von 500 auf 4000. Bei Ueberlauf wird die
Original-Byte-Anzahl im Result mitgegeben damit ARIA weiss dass mehr
Daten da gewesen waeren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:37:28 +02:00
duffyduckandClaude Opus 4.7 e7fd918559 fix(brain): zwei neue seed_rules — kein Sub-Agent fuer Skills + Anti-Halluzination
Live-Beobachtung am 30.05.2026: ARIA spawnte `Agent` (Sub-Agent) mit
Anweisung 'Call run_spotify...' statt das Tool direkt aufzurufen. Der
Sub-Agent ist eine isolierte Claude-CLI-Session ohne Brain-Tools, hat
also 'No such tool: run_spotify' gemeldet. ARIA hat dann halluzinierte
Track-Namen ausgegeben ('Set You Free – N-Trance', 'Tomcraft –
Loneliness'), als waeren das echte Spotify-Daten.

Drei distinkte Probleme, zwei neue Regeln:

13. seed/skill-rule/no-subagent-for-skills:
    Brain-Tools (run_*, oauth_*, memory_* …) NIEMALS via Agent-Subagent
    aufrufen — die sind isoliert und sehen die Brain-Tools nicht.
    Direkt in der Haupt-Session aufrufen. Subagent nur fuer Code-Search
    / Web-Recherche / parallele unabhaengige Aufgaben.

14. seed/rule/no-hallucinated-results (Kategorie 'ehrlichkeit'):
    Bei Tool-Fail / abgeschnittenem Response / fehlendem Tool: ehrlich
    sagen, NICHT raten. Anti-Antipattern: 'Stefan vertraut Deinen
    Antworten — wenn Du raetst und es als Fakt verkaufst, bricht das
    Vertrauen'. Mit konkreten Formulierungs-Beispielen.

Beide Regeln sind erfahrungsbasiert (mit Datum + konkretem Vorfall) —
ARIA sieht im Hot-Memory was sie selbst falsch gemacht hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:29:41 +02:00
duffyduckandClaude Opus 4.7 bb3c7957aa fix(brain): re-Modul in agent.py importieren — fehlte fuer safe-name-Mapping
Letzter Fix-Commit nutzt re.sub() in _skill_to_tool und im Dispatcher,
aber re wurde nie oben importiert. Folge: NameError beim ersten chat()
Aufruf nach Restart. Stefan bekam Brain-Error 500.

Trivial-Fix: import re bei den anderen stdlib-Imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:23:58 +02:00
duffyduckandClaude Opus 4.7 89cafa6251 fix(brain): Skill-Namen snake_case — neue Skills entstehen direkt sauber
Stefan-Frage: 'weiss sie in zukunft unterstriche statt bindestriche?'
Antwort vorher: nein — Tool-Description sagte 'kebab-case'. Genau das
hat die Bindestrich-Skills produziert die gestern die Tool-Liste kippten.

Drei Aenderungen:
- skill_create Tool-Description: 'kurz, kebab-case' → 'snake_case (NUR
  a-z 0-9 _). KEINE Bindestriche — die brechen das Tool-Schema beim
  claude-max-api-proxy. Statt yt-dlp-download → yt_dlp_download.'
- skill_scaffold Tool-Description: gleiche Klarstellung.
- 12. seed_rule snake-case-names: erklaert das Verbot mit Begruendung
  (proxy-Limitierung), Beispielen RICHTIG/FALSCH und Hinweis dass
  historische Skills mit Bindestrich ueber das Safe-Name-Mapping laufen
  (nicht umbenennen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:19:49 +02:00
duffyduckandClaude Opus 4.7 1ea7ab5ab1 fix(brain): run_<skill> Tool-Namen safe escapen — Bindestriche kippten Tools-Liste
Beobachtung beim zweiten Live-Test (01:13:41): ARIA versuchte echten
Tool-Call `run_spotify` — bekam aber Error: 'No such tool available'.

Ursache: _skill_to_tool baute Tool-Namen via `run_{s['name']}`. Bei
Skills wie 'yt-dlp-download' wurde daraus 'run_yt-dlp-download' mit
Bindestrich. Anthropic-Tool-Name-Schema ist eigentlich [a-zA-Z0-9_-],
ABER der claude-max-api-proxy konvertiert intern auf OpenAI-Format
und faellt bei Bindestrichen um — wenn EIN Tool ungueltig ist, kippt
die GANZE Tool-Liste, ARIA sieht nichts von 'run_*' inklusive
'run_spotify' obwohl der ja Bindestrich-frei war.

Fix:
- _skill_to_tool: name = "run_" + re.sub(r"[^a-zA-Z0-9_]", "_", s["name"])
  → run_yt_dlp_download statt run_yt-dlp-download.
- Dispatcher: bei tool_name='run_X' wird zuerst X als skill_name probiert,
  bei Miss wird ueber die Liste der existierenden Skills gemappt — der
  Skill mit safe_name(name)==X wird dann genommen.
- Bypass-Lesson + Bypass-Section: gleiche safe-Logik fuer den
  empfohlenen run_<tool>-String im Memory/Prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:17:48 +02:00
duffyduckandClaude Opus 4.7 15f95ed196 fix(brain): tools nach auto_scaffold neu bauen — sonst halluziniert ARIA Tool-Tags
Beobachtung beim ersten Live-Test (00:58:33): Auto-Scaffold legte den
spotify-Skill mid-chat() an, all_skills + active_skills wurden refreshed,
ABER die `tools=`-Liste die an den Proxy/claude-CLI geschickt wird
nicht. Folge: ARIA sah im System-Prompt-Skills-Block dass `spotify`
existiert und wusste sie soll `run_spotify` nutzen — aber claude-CLI
kannte das Tool nicht weil dessen tool-schema noch ohne run_spotify
war. Sie hat dann <tool_call name="run_spotify">...</tool_call> als
XML in den Text geschrieben, das wurde nirgends ausgefuehrt (siehe
"Pausiert" / "Restricted & NIKSTER" Antworten waren halluziniert).

Fix in 4 Zeilen: nach scaffolded_any auch `tools = list(META_TOOLS) +
[_skill_to_tool(s) for s in active_skills]` neu bauen. Damit kennt der
CLI-Subprocess den frischen Skill-Tool sofort und kann ihn echt aufrufen.

Beim naechsten chat-Turn waere es eh richtig (Tools werden neu gebaut),
aber genau der erste Turn nach Auto-Scaffold ist der wichtigste —
da soll's klappen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:03:21 +02:00
duffyduckandClaude Opus 4.7 210ce62ffe feat(brain): Skill-Bypass-Detection + Bypass-Lehre als pinned Memory
Variante 3+ (Lerneffekt-Variante): Variante C scaffolded zwar Skills auto,
aber ARIA lernt nicht — sie wird beim naechsten Mal trotzdem zu Bash
greifen. Stefans Punkt: Lernen geht nur ueber Brain-Memory.

Mechanik:
1. api_heuristic.detect_recent_bypass(skills, since_sec=600):
   schaut letzte 10 Min im agent_stream.jsonl, findet Bash-curl gegen
   Hosts fuer die bereits ein matching Skill existiert. Returnt
   {host, skill_name, count, last_ts}.

2. api_heuristic.build_bypass_section(events):
   Drastischer Markdown-Block "## 🚨 SKILL-BYPASS ERKANNT" mit konkretem
   run_<skill>-Hint pro betroffenem Host. Landet direkt im System-Prompt
   noch VOR dem normalen API-Heuristik-Block.

3. agent.py._upsert_bypass_lesson(ev):
   Schreibt eine pinned type=rule Memory mit source=auto-feedback und
   migration_key=auto/skill-bypass/<skill_name>. Idempotent: bei
   Wiederholung wird die alte Memory ueberschrieben (Counter aktualisiert),
   keine Karteileichen. Content nennt konkret den run-Tool-Namen und
   Performance-Vergleich (3s Tool-Call vs 13-20s Bash-Wrapper).

Diese Memory ist permanent pinned → kommt bei jedem Chat-Turn,
cross-session, cross-restart als Hot-Memory durch. Damit lernt ARIA
es im wortlichen Sinne, nicht nur Reibung in der aktuellen Konversation.

Idempotenz wichtig: bei jedem Bypass-Detection-Lauf wird die Memory
upgedatet (nicht dupliziert). Stefan kann sie via Diagnostic-Gehirn-Tab
loeschen falls sie nervt.

Stefan-Frage beantwortet: 'sie wuerde es aber nur lernen wenn sie es
auch im gehirn speichert oder?' — exakt. Schimpfen im Prompt ist
Reibung dieser Session, pinned Memory ist permanenter Lerneffekt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:37:40 +02:00
duffyduckandClaude Opus 4.7 298b2202a1 feat(brain): Auto-Scaffold — Brain legt Skills selbst an wenn ARIA driftet
Variante C: ARIA hat selbst mit Heuristik-Block + 11 seed_rules den
expliziten skill_scaffold-Befehl ignoriert (32x Spotify-Bash-Calls in
24h, kein einziger scaffold-Aufruf). Verhaltens-Traegheit ist staerker
als jeder Prompt-Hint.

Loesung: Brain wartet nicht mehr. Bei jedem chat()-Aufruf wird die
Heuristik berechnet. Findet sie einen Host mit bekannter Suggestion
(Spotify, GitHub, OpenAI, OpenWeather, Telegram, Microsoft, Discord,
Notion, Reddit) der noch keinen Skill hat → Brain ruft selbst
`scaffold_skill(name, template, params)` mit author='aria-auto'.

Der frische Skill ist sofort im Prompt sichtbar (Skill-Liste wird nach
Scaffold refreshed, Heuristik-Cache invalidiert, Hints neu gerechnet).
Side-Channel-Event 'skill_created' mit Flag 'auto_scaffolded' geht an
die UI — Stefan sieht im Chat dass Brain einen Skill angelegt hat.

ARIA findet beim Tool-Use-Loop einen passenden `run_<name>`-Skill vor
und nutzt ihn idealerweise statt wieder Bash. Macht sie's nicht und
curlt trotzdem weiter, ist der Counter beim naechsten Mal wieder hoch
und Brain scaffolded weiter — aber dann ist der Skill ja schon da, also
nur ein Pfad.

Toggle: BRAIN_AUTO_SCAFFOLD=false zum Abschalten.

scaffold-reflex Regel angepasst: ARIA wird informiert dass Brain
manchmal selbst scaffolded (author=aria-auto) und sie den Skill via
run_<name> nutzen soll statt zu curlen. Bei Hinweisen OHNE Suggestion
(unbekannter Host) soll sie selbst skill_scaffold rufen.

Stefan-Zitat aus der Diskussion ("ARIA lernt es so nicht"): stimmt
inhaltlich, aber pragmatisch wichtiger ist dass Stefans Wartezeit von
20s auf 3s sinkt. Lernen kann sie spaeter — der Skill ist da, sie sieht
den Pfad jedes Mal beim Tool-Listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:28:15 +02:00
duffyduckandClaude Opus 4.7 845a8b0020 feat(brain): API-Heuristik — Cross-Session-Counter fuer Skill-Drift
Variante B: scaffold-reflex Regel allein reicht nicht weil jede Chat-
Anfrage eine eigene claude-CLI-Session ist. ARIA sieht in der aktuellen
Session nicht dass sie gestern auch schon 10x dieselbe API gecurled hat.
Beobachtung: 5+ Spotify-Bash-Calls hintereinander, kein Skill angelegt.

Loesung: Brain trackt server-side aus dem persistierten agent_stream.jsonl.
Bei jedem chat() wird der Log gescanned (cache 5min), Bash-curl-Calls
nach Hostname aggregiert. Hosts mit >=3 Calls in 24h ohne passenden
Skill landen als '## API-Heuristik'-Block im System-Prompt mit konkretem
skill_scaffold-Vorschlag.

Neue Module:
- aria-brain/api_heuristic.py:
  - compute_hints(existing_skills, force): Aggregiert + filtert
  - build_section(hints): formatiert als kompakten Markdown-Block
  - Smart suggestions mapping (api.spotify.com → oauth-api template etc.)
  - Ignoriert interne Hosts (aria-brain, localhost, docker-bridge)
  - 5-min Cache damit nicht jeder Turn die JSONL parst

- aria-brain/prompts.py: build_system_prompt nimmt api_heuristic_section
  als optionalen Block direkt nach Skills-Section.

- aria-brain/agent.py: vor build_system_prompt Heuristik berechnen mit
  aktueller Skill-Liste, Block durchreichen.

- 11. seed_rule scaffold-reflex umgeschrieben: kein 'in einer Session'
  mehr (das ergab keinen Sinn — jeder Turn neue Session). Stattdessen:
  '## API-Heuristik'-Block ist Dein Cross-Session-Gedaechtnis. Wenn da
  was steht: scaffolden BEVOR Du Bash machst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:19:06 +02:00
duffyduckandClaude Opus 4.7 0540c49c66 feat(brain): skill_scaffold — Templates statt Skill aus dem Nichts
Variante C: niedrigere Huerde zum Skill-Bau. Statt einen kompletten
Python-Skill via skill_create zu generieren (~100 Zeilen Code, teuer in
Tokens und fehleranfaellig), waehlt ARIA ein Template + minimale params,
Brain expandiert das Skelett in ~1s zu fertigem Skill.

Beobachtung: ARIA driftet bei Spotify, PDF etc. zu Bash-curl statt
einen Skill zu bauen, weil die Skill-Bau-Huerde zu hoch ist (Code,
README, args, pip_packages, config_schema). Mit Templates ist die
Huerde minimal.

Neue Module:
- aria-brain/skill_templates.py: drei mitgelieferte Templates
  - oauth-api: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, ...).
    Token via BRAIN_INTERNAL_URL/oauth/<s>/token mit Auto-Refresh.
    Args: method/path/body/base_url
  - apikey-api: API mit statischem Key (OpenWeather, OpenAI, Twilio).
    Key liegt im config_schema -> CFG_<NAME> ENV, KEIN hardcoden.
    Konfigurierbar: auth_header (Authorization|X-Api-Key), auth_prefix.
  - file-process: Skelett fuer File-In/File-Out (PDF, Bild, JSON).
    process()-Funktion ist Stub, ARIA fuellt sie via skill_update.
  Templates nutzen Token-Replacement statt f-Strings (sonst Konflikt
  mit dem skill-internen Python-Code).

- aria-brain/skills.py: scaffold_skill(name, template, params, author)
  wrappt create_skill mit den expandierten Feldern.

- aria-brain/agent.py: neues Brain-Tool skill_scaffold mit detaillierter
  Description (Template-Liste + params-Schema). Dispatcher-Handler
  schickt skill_created Side-Channel-Event analog zu skill_create.

- aria-brain/main.py: POST /skills/scaffold + GET /skills/templates
  (letzteres listet alle Templates fuer UI/Diagnostic).

- 11. seed_rule scaffold-reflex: bei 2x derselben API per Bash-curl
  SOFORT skill_scaffold rufen. Belohnung explizit benannt
  ("welches lied" von 20s auf 3s).

README mit Skills-Scaffold-Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:02:45 +02:00
duffyduckandClaude Opus 4.7 add303970b feat(brain): 10. seed_rule — runtime-topology (wo ARIA tatsaechlich laeuft)
Beobachtung beim "ueberspringe Lied"-Test (29.05.2026): 47 Sekunden mit
12 fehlgeschlagenen Bash-Versuchen weil ARIA glaubte sie sei im
aria-brain Container. Sie hat probiert:

  - python3/python/jq (Alpine — alle nicht installiert)
  - cd /data/skills/spotify-control (existiert nur im Brain)
  - curl localhost:8080/oauth/... (localhost = aria-proxy, nicht Brain)
  - 8s Timeout auf localhost (kein TCP Reset)

Erst nach 9 Versuchen brain:8080 erraten und dann den Token-Wert
hardcoded in den naechsten curl gepackt.

Die neue Regel beschreibt die echte Topologie explizit:

- Du bist die claude-CLI als Subprocess IM aria-proxy (node:22-alpine)
- KEIN python3/python/jq verfuegbar
- /data/skills/ existiert NUR im aria-brain
- localhost in Deinem Bash heisst aria-proxy; Brain ist aria-brain:8080
- BRAIN_INTERNAL_URL ist NUR in laufenden Skills gesetzt
- Brain-Resources via Brain-Tools (oauth_get_token, memory_search,
  run_<skill_name>), NICHT via Bash
- SSH zur VM-Host: `ssh aria@host` (ed25519-Key liegt im Proxy)
- Externe APIs direkt per curl mit Token aus oauth_get_token

Plus das Anti-Pattern dokumentiert ("47 Sekunden Stefan-Lebenszeit") —
ARIA soll bei jedem Bash-Reflex gegen "lokale" Brain-Resources erst
denken oder die Brain-Tool-Ebene nehmen.

README in Skills-Architektur-Sektion entsprechend ergaenzt (10 Regeln).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:30:50 +02:00
duffyduckandClaude Opus 4.7 fb71048dfd feat(diagnostic): Archiv-Modal mit Pagination fuer ARIA-Stream
- /api/agent-stream akzeptiert jetzt ?page=N&perPage=M zusaetzlich zu
  ?lines=N. page=1 = neueste Eintraege, hoehere Pages = aelter.
  Antwort enthaelt page/perPage/pagesTotal/total fuer Client-Nav.
- Live-View hat neuen 📜 Archiv-Button neben Leeren/Auto-Scroll.
- Modal mit PerPage-Selector (50/100/500/1000), «‹›» Navigation und
  reload-Button. Pagination-Buttons werden auf den Grenzen disabled.
- renderArchiveLine spiegelt das Live-View-Rendering (Tool-Calls in
  cyan, Results in gruen, Thinking kursiv) im Modal-Container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:11:46 +02:00
duffyduckandClaude Opus 4.7 aaaf118cb7 feat: 2 neue seed_rules + Diagnostic-Persistenz fuer agent_stream + chat-backup API
Befund aus chat_backup.jsonl-Analyse heute: ARIA ist 3x auf oauth_authorize
gefallen statt oauth_get_token (Stefan musste manuell einloggen), und beim
PDF-Skill ist sie nach Stefans "Variante bitte" zu Ad-hoc-Bash-Befehlen
auf der VM gedriftet ("ich lass den Code direkt laufen") — Skill wurde
unbrauchbar. Beides genau die Antipattern die wir mit den seed_rules
abdecken wollten, nur waren die zu schwach formuliert.

seed_rules (jetzt 9 statt 7):
- oauth-reauth-reflex: bei 401 ZUERST oauth_get_token, NUR bei dessen
  Fehler oauth_authorize. Stefan zu Re-Login schicken ist das aergerlichste
  Antipattern (er sitzt im Auto, muss Handy rauskramen).
- no-skill-drift: kaputter Skill -> skill_logs + skill_update, NIEMALS
  zu Ad-hoc-Bash wechseln (Skill wird Karteileiche). Plus: "ich baue
  dir einen Skill" SAGEN ohne skill_create zu rufen ist verboten —
  Stefan checkt die Liste und verliert das Vertrauen.

agent_stream-Persistenz:
- diagnostic/server.js schreibt jeden agent_stream-Event parallel zum
  Broadcast in /shared/logs/agent_stream.jsonl (soft-cap 50 MB mit
  half-truncate beim Ueberlauf).
- Live-View laedt beim Page-Load + Sub-Tab-Switch die letzten 200
  Eintraege via /api/agent-stream. Browser-Reload / Standby verliert
  damit den Verlauf nicht mehr.

Debug-API ohne SSH:
- GET /api/chat-backup?lines=N (Default 200, Max 5000) — geparstes JSON
  der letzten N Zeilen aus chat_backup.jsonl
- GET /api/agent-stream?lines=N — gleiches fuer den persistierten Stream

README:
- Neuer Abschnitt "## Skills — Architektur" mit Skill-Layout,
  Drei-Stufen-Daten-Modell (OAuth / config_schema / Brain-Daten),
  Versionierung, Anti-Friedhof, seed_rules (alle 9 aufgelistet).
- Diagnostic-Sektion um agent_stream-Persistenz + neue Debug-Endpoints
  ergaenzt.
- Roadmap: Phase B "Skill-Architektur P0-P4" abgehakt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:06:56 +02:00
duffyduck 5e1cb2d26a release: bump version to 0.1.6.3 2026-05-28 23:58:26 +02:00
duffyduckandClaude Opus 4.7 8359500476 feat(skills): P3 config_schema + P4 Versionierung mit Rollback
P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
  als zentrale Werte-Persistenz. _normalize_config_schema validiert die
  Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
  ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
  config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.

P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
  versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
  bevor strukturelle Aenderungen passieren. list_skill_versions,
  rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
  delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
  DELETE /skills/{name}/versions/{version_id}.

UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
  Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
  mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
  on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
  Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
  getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
  deleteSkillVersion erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:52:46 +02:00
duffyduckandClaude Opus 4.7 1a72f27861 feat(brain): seed_rules erweitert — BRAIN_INTERNAL_URL + Auth-Strategie
ARIA wusste bisher nichts von BRAIN_INTERNAL_URL — sie hatte den Endpoint
zwar, aber keinen Grund ihn zu nutzen. Zwei neue rule-Memories:

- "BRAIN_INTERNAL_URL ist deine Brain-Schnittstelle" — listet die
  wichtigsten Endpoints (oauth/<service>/token, memory/search,
  memory/pinned, skills/list) und macht klar dass auch Daten wie
  Stefans Standort, Memories oder andere Skills aus dem Skill heraus
  abrufbar sind.
- "Auth-Strategie fuer externe APIs" — zwingt ARIA bei jedem API-Skill
  in eine Checkliste: erst OAuth2 pruefen (Spotify, Google, GitHub,
  Reddit, …), sonst statischer Key per config_schema, NIEMALS hardcoden.

Damit kommt sie eigenstaendig auf "Spotify = OAuth2 = Brain-Endpoint"
ohne dass Stefan das jedes Mal sagen muss. Insgesamt jetzt 7 seed_rules
statt 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:11:02 +02:00
duffyduckandClaude Opus 4.7 32302a841e feat(brain): Skills holen OAuth-Tokens vom Brain + Anti-Friedhof-Check
P1+P2-Infrastruktur:

- Neuer Endpoint GET /oauth/{service}/token liefert aktuelles access_token
  mit Auto-Refresh (< 60s Restzeit). Skills rufen das ueber
  BRAIN_INTERNAL_URL ab statt client_secret hardzucoden.
- run_skill setzt BRAIN_INTERNAL_URL als ENV (Default http://localhost:8080,
  override via Brain-Env). Skills laufen im Brain-Container, localhost passt.
- skills.create_skill: _check_anti_graveyard rejected Versions-Suffixe
  (-v2, _v3, -new, -fixed, -old, -alt, -copy, -final, -clean) und
  Prefix-Kollisionen (z.B. spotify-aria wenn spotify schon existiert) — die
  zwei Patterns hinter dem alten Skill-Friedhof.

Tool-Description fuer skill_create um PFLICHT-VORHER-Block ergaenzt
(skill_list, kein Versionssuffix, oauth_get_token, config_schema) damit
ARIA die Regeln direkt im Schema sieht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:04:22 +02:00
duffyduckandClaude Opus 4.7 474e2c6c50 feat(brain): Skill-Regeln als seed_rules — idempotent auf Brain-Boot in DB
Stefans Skill-Friedhof (9 Spotify-Skills, hardcoded Credentials) hatte
keine systemische Ursache im Code, sondern im fehlenden Leitplanken-
Memory. Lösung: System-Seed-Regeln als pinned Hot Memory, mit jedem
Deploy ausgerollt.

- aria-brain/seed_rules.py: 5 rule-type Memories (skill_list-vor-create,
  no-version-suffix, update-not-recreate, no-hardcoded-credentials,
  config-schema-for-settings), source="seed", pinned=true
- Lifespan ruft seed_rules.apply() beim Brain-Start — idempotent via
  migration_key (alte Versionen werden vor dem Schreiben gelöscht)
- skill_create Tool-Description um PFLICHT-VORHER-Block ergänzt:
  skill_list-check, kein Versionssuffix, oauth_get_token bei OAuth,
  config_schema statt hardcoded Werte

Editieren = SEED_RULES-Liste anpassen, Brain neu starten. Im Gegensatz
zu brain-import/ (User-Saatgut, gitignored, manueller Diagnostic-Klick)
gehört das hier zum Brain-Code und rollt mit jedem Deploy aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:55:05 +02:00
duffyduck 3e0cfef63c changed docker compose rvs to 444 2026-05-25 10:31:27 +02:00
duffyduckandClaude Opus 4.7 b94626787b fix(diagnostic): chat_history-Render verträgt kaputte Bubbles + EHOSTUNREACH skipped TLS-Fallback
Zwei kleine Robustness-Verbesserungen:

1) chat_history-Handler im Frontend: jede Bubble jetzt in try/catch. Wenn
   eine Bubble bei der Render-Pipeline (escape/linkify/regex-replace) eine
   Exception wirft, brach die ganze for-Schleife ab und alle nachfolgenden
   Bubbles wurden nicht mehr in den DOM geschrieben — beim Reload sah man
   dann nur die ersten N Eintraege und Stefan dachte die letzten Antworten
   waeren weg. Jetzt: Fehler-Bubble mit "⚠ Render-Fehler" + console.error,
   restliche Bubbles laufen weiter durch.

2) Diagnostic-Server RVS-Reconnect: TLS-Fallback war auch bei reinen
   Netz-Fehlern (EHOSTUNREACH, ECONNREFUSED, ENETUNREACH, ETIMEDOUT,
   ENOTFOUND, EAI_AGAIN) gefeuert — bringt nichts weil der Server eh tot
   ist, generiert aber doppelte Reconnect-Versuche + Log-Spam. Jetzt nur
   noch bei wirklichen TLS/Handshake-Fehlern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:28:57 +02:00
duffyduckandClaude Opus 4.7 ad87c807de fix(app): App-Reconnect nach Hintergrund — Sticky-Fallback, Zombie-WS, AppState-Hook
Stefan musste seit der HTTPS-Umstellung nach jedem Hintergrund-Rueckkehr
manuell auf "Verbinden" tippen, meist 3x bis es ging. Gleiche Bug-Klasse
wie auf der Bridge davor (Sticky-Fallback), plus zwei App-spezifische
Symptome.

Drei Ursachen:

1. usingTLSFallback klebt: einmal nach onerror auf true gesetzt, blieb
   es bei allen folgenden Reconnects → App versuchte ws://...:443 gegen
   den TLS-only Caddy → HTTP 400 → endlos. Reset war NUR im manuellen
   connect(), nicht in onclose oder scheduleReconnect.
   Fix: in onclose `usingTLSFallback = false` damit der naechste
   Reconnect wieder primary (wss://) probiert.

2. Zombie-WebSocket: Android kann den TCP-Socket im Background still
   killen, der JS-State zeigt aber noch readyState === OPEN. Stefans
   manueller "Verbinden"-Klick rief connect() → "Bereits verbunden"
   No-Op statt sich neu aufzubauen.
   Fix: connect(force=true) optional, bestehendes WS-Objekt wird hart
   geschlossen (mit onclose=null gegen Doppel-Reconnect) bevor neuer
   Aufbau startet.

3. Keine aktive Reconnect-Sequence bei Foreground-Resume: App war
   abhaengig von onclose-Events die bei Zombie-WS nicht zwingend
   feuern.
   Fix: AppState-Listener in App.tsx, bei background → active
   automatischer rvs.connect(true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:09:30 +02:00
duffyduck 72277098af release: bump version to 0.1.6.2 2026-05-25 10:00:47 +02:00
duffyduckandClaude Opus 4.7 80d2fe3e93 docs: README aktualisiert — FLUX, ARIA Live, OAuth + Caddy, Skill-Mgmt, Bridge-Watchdog, Bubble-Aktionen
- Diagnostic-Sektion: OAuth-Apps zeigt jetzt Spotify-Default + on-demand-
  Provider statt fixe 5er-Liste, `oauth_register_provider` als 4. Tool
  erwaehnt, Caddy/Let's-Encrypt vor RVS dokumentiert
- App-Features: Long-Press/⎘-Bubble-Aktionen + System-Share, neue Settings-
  Sektionen "🛠️ Skills" und "🔑 OAuth-Apps", Voice-Speed persistent
- Voice-Bridge-Sektion: 3-Schichten Hang-Schutz (TCP-Keepalive +
  Asyncio-Watchdog + File-Based Liveness) erlaeutert, TLS-Fallback-Reset
- Roadmap Phase B: sechs neue Eintraege fuer die letzten ~10 Commits
  (FLUX, ARIA Live + Not-Aus, OAuth-Pipeline, Skill-Mgmt-Tools,
  Bridge-Hang-Schutz, Bubble-Aktionen)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:55:28 +02:00
duffyduckandClaude Opus 4.7 b5ca3cd371 fix(bridge): TLS-Fallback klebt nicht mehr — bei Reconnect zurueck zu wss://
Bei kurzem TLS-Fehler beim ersten Connect (z.B. Caddy noch im ACME-
Setup) wechselte die Bridge auf den ws://-Fallback und blieb dort
permanent kleben. Jeder spaetere Reconnect-Versuch landete dann auf
plain ws:// gegen den TLS-only Caddy-Endpoint → HTTP 400 → erneut
Connection lost → endlos.

Fix: Bei jeder ConnectionClosed/Refused/InvalidMessage-Exception wird
using_fallback=False und current_url=self.rvs_url (= primary wss://)
zurueckgesetzt. Bridge probiert bei jedem Reconnect zuerst primary,
faellt nur einmal pro Connect-Cycle auf ws:// zurueck. Sobald TLS
verfuegbar ist, ist sie auf wss:// stabil.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:50:43 +02:00
duffyduckandClaude Opus 4.7 d939fc4ac3 feat(rvs): Caddy als TLS-Terminator + Let's Encrypt vor RVS
OAuth-Provider (Spotify, Dropbox, ...) verlangen HTTPS fuer non-localhost
Redirect-URIs. Bisher lief der RVS direkt auf einem TCP-Port ohne TLS —
Spotify hat den Callback abgewiesen.

Loesung: Caddy im selben Compose-Stack davor. Holt automatisch ein
Let's Encrypt-Zertifikat fuer PUBLIC_URL (HTTP-01 ueber Port 80),
terminiert TLS auf 443 und routet alles inkl. WebSocket-Upgrades an
den internen RVS-Container (Port 3000).

- rvs/docker-compose.yml: caddy-Service hinzu (image caddy:latest,
  command 'caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000'),
  rvs-Service verliert ports-Block (nur intern via aria-rvs-net),
  data-Volumes fuer Caddy-ACME-State (persistent, Rate-Limit-Schutz).
- rvs/.env.example neu: dokumentiert PUBLIC_URL + DNS/Port-
  Voraussetzungen.
- rvs/.gitignore neu: .env + data/ (sonst landen die Zertifikate
  versehentlich im Repo).
- README RVS-Sektion: Setup-Schritte mit Caddy + Hinweis wie man's
  auskommentiert wenn ein eigener Reverse-Proxy davor steht.

Wer schon einen TLS-Terminator hat (nginx/Traefik): caddy-Service in
der Compose auskommentieren, rvs wieder einen ports-Block geben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:23:28 +02:00
duffyduckandClaude Opus 4.7 13e87fb083 feat(oauth): ARIA kann Provider selbst registrieren + Custom-Provider in Diagnostic & App
ARIA hat jetzt das META-Tool oauth_register_provider. Wenn Stefan einen
Service nutzen will, der nicht in den (auf Spotify reduzierten) Defaults
ist, kann sie auth_url/token_url/scopes/client_auth selbst eintragen —
ARIA kennt typische OAuth-Endpunkte (Dropbox, Discord, Notion, Slack,
Zoom, Trello, LinkedIn, Reddit, Twitch) aus ihrem Training. Sie traegt
NUR die URLs ein, client_id/secret bleiben Stefans Job (Diagnostic /
App-UI) — bewusste Trennung damit Credentials nicht im Chat-Verlauf
landen.

DEFAULT_PROVIDERS auf Spotify reduziert — Rest war aktuell ungenutzt
und macht den Code unnoetig "groß". ARIA registriert on-demand.

Diagnostic-UI:
- Custom-Provider zeigen auth_url/token_url/scopes als sichtbare Felder
- Defaults verstecken die Felder hinter "Default-URLs ueberschreiben
  (advanced)" damit man die Spotify-URLs nicht versehentlich loescht
- "+ Custom OAuth-Provider hinzufuegen" Button mit Prompts fuer
  Name/URLs/Scopes
- 🗑-Icon bei Custom-Services (Service komplett entfernen)

App-UI (neu fuer unterwegs):
- Settings → Sektion 🔑 "OAuth-Apps" zwischen Skills und Protokoll
- OAuthBrowser-Komponente analog zu Trigger/Skill-Browser:
  Liste mit Status, Tap → Edit-Modal mit client_id/secret +
  Advanced-Toggle fuer URLs. "Autorisieren ↗" oeffnet System-Browser
  via Linking.openURL, redirected zur RVS-Callback-Page,
  Status-Refresh nach 8s.
- "+ Custom"-Button → Full-Screen-Modal fuer Service-Anlage.
- brainApi um listOAuthServices/getOAuthApps/saveOAuthApp/
  deleteOAuthApp/authorizeOAuth/revokeOAuth erweitert.

Workflow ist jetzt: "verbinde mich mit Dropbox" → ARIA registriert
Provider → "trag client_id/secret in Settings ein" → Stefan macht das
in App oder Diagnostic → "Autorisieren ↗" → fertig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:16:31 +02:00
duffyduckandClaude Opus 4.7 30c1dd7473 feat(app+brain): App-Bugfixes + Skill-Mgmt-Tools + Voice-Speed persistent + Skill-Browser
App-Bugs:
- Trigger-Liste war leer: brainApi.listTriggers() cast'te {triggers: [...]}
  direkt als Array, t.sort() warf — TriggerBrowser blieb leer. Fix: unwrap.
- GPS-Tracking startete erst bei SettingsScreen-Mount, nicht beim App-Boot.
  Wenn Stefan direkt in den Chat ging, blieb GPS aus. Fix: restoreFromStorage()
  in App.tsx useEffect.
- Text in Chat-Bubbles nicht markierbar / kein Copy-Mechanismus: Bubble jetzt
  Pressable mit onLongPress + neues ⎘-Icon in Status-Row → openBubbleActions().
  Alert-Menu mit "Ganzen Text teilen" + pro extrahierte URL/Mail/Tel eine
  eigene Option. Share.share() — keine neuen Native-Deps noetig.

Brain — Skill-Mgmt:
- ARIA legte beim Skill-Umbau neue Versionen mit Suffix an (Skill-Friedhof),
  weil sie kein Update/Delete-Tool kannte. Zwei neue META_TOOLS in agent.py:
  skill_update (kann entry_code, readme, pip_packages, args, description,
  active patchen — venv wird bei pip_packages-Aenderung rebuilt) + skill_delete.
- skills.py update_skill um entry_code/readme/pip_packages erweitert,
  venv-Rebuild bei pip-Aenderung.

Bridge — Voice-Speed persistent:
- _next_speed_override war pro-Request-Override ohne Persistenz. Bei
  Diagnostic-Chats / Trigger-Replies ohne vorherigen App-Chat fiel der Speed
  auf 1.0 zurueck, ebenso nach Bridge-Restart. Jetzt: _persistent_xtts_speed
  aus voice_config.json (xttsSpeed), wird nach jedem App-chat mit speed
  autopersistiert. TTS-Generation faellt zurueck: per-Request > persistent > 1.0.

App — Feature 6:
- SkillBrowser.tsx: Liste aller Skills, Toggle aktiv/inaktiv, Detail-Modal
  mit Args-Inputs, Ausfuehren mit Live-stdout/stderr, Logs der letzten 20
  Runs, Loeschen. Settings-Sektion "Skills" (🛠️) zwischen Trigger und
  Protokoll. brainApi.listSkills/getSkill/runSkill/updateSkill/deleteSkill/
  getSkillLogs ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:24:03 +02:00
duffyduckandClaude Opus 4.7 9ed9c99b0e fix(bridge): 3-Schichten-Schutz gegen Bridge-Hangs + Chat-History in beide Boxen
Bridge hat seit 5+h still gehangen — Container Up, asyncio idle im
selectors.select(), TCP-Verbindung zum RVS ESTABLISHED, aber keine
Events mehr verarbeitet. Klassischer Fall: NAT-Tabelle/Firewall hat
die TCP-Verbindung still gekillt (kein RST), Linux-Kernel mit Default-
Keepalive (2h idle) hat's nicht gemerkt, und der ws.ping()-Future hat
im Limbo gehangen ohne Exception zu werfen.

Schicht 1 — TCP-Keepalive aufm Socket:
  SO_KEEPALIVE=1, TCP_KEEPIDLE=30s, TCP_KEEPINTVL=10s, TCP_KEEPCNT=3.
  Halb-tote Verbindungen werden in ~1 min mit ECONNRESET sichtbar statt
  nach 2h. Loest 80% der Faelle direkt.

Schicht 2 — Asyncio-Watchdog (_rvs_heartbeat_watchdog):
  Separate Coroutine parallel zu _rvs_heartbeat. Letzterer markiert
  _last_heartbeat_ok nach jedem erfolgreichen pong. Watchdog checkt
  alle 20s: > 60s stale → ws.close() + transport.close() als Notausgang.
  Schuetzt gegen ws.ping()-Limbo.

Schicht 3 — File-Based Liveness Thread:
  Separater OS-Thread (NICHT asyncio) — immun gegen asyncio-Hangs.
  Schreibt /shared/health/bridge_alive periodisch. Wenn
  _last_heartbeat_ok > 180s stale: os._exit(1), Docker restart_policy
  uebernimmt. Last-Resort wenn Schichten 1+2 versagen.

Plus: chat_history-Render nach Reload bezog nur #chat-box, nicht
#chat-box-fs (Vollbild). Wer im FS-Modus reloaded hat sah eine leere
Box statt der History. Jetzt rendert der Handler in beide Boxen
(gleicher Pattern wie addChat / addAriaFile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:39:52 +02:00
duffyduckandClaude Opus 4.7 1ea614c26b fix(brain): CPU-only torch — verhindert 5 GB CUDA-Bloat im Brain-Image
sentence-transformers zieht torch als Dependency, und der Default-Wheel
auf x86_64-linux ist die CUDA-Variante mit allen NVIDIA-Libs
(nvidia-cudnn, nvidia-cublas, cuda-toolkit, triton, ...). ~5 GB pro
Build-Layer, frisst die 22-GB-VM auf.

Fix: torch CPU-Wheel explizit zuerst installieren. Damit ist die
torch-Dependency erfuellt wenn sentence-transformers spaeter kommt,
und die CUDA-Libs werden nie gezogen.

Brain laeuft eh komplett auf CPU (MiniLM-Embeddings ~120 MB), GPU-Bloat
war reine Disk-Verschwendung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:45:51 +02:00
duffyduckandClaude Opus 4.7 acaa9fc3f2 feat(oauth): generische OAuth2-Pipeline ueber RVS-Callback (Spotify/Google/GitHub/Strava/MS)
Bisher musste Stefan bei OAuth-Flows manuell den Auth-Code aus der
Browser-URL kopieren (redirect_uri war localhost). Jetzt: RVS hat einen
HTTP-Listener auf demselben Port wie der WebSocket, Provider redirecten
nach Auth zu https://{RVS_HOST}/oauth/callback/{service}, RVS broadcastet,
aria-bridge forwarded, Brain matched state + tauscht code gegen Token.
Token-Refresh laeuft automatisch.

- rvs/server.js: hybrid http.createServer + WebSocketServer{noServer}.
  Route GET /oauth/callback/{service}, broadcast oauth_callback an alle
  Raeume, schoene Dark-Mode-HTML-Antwort an den Browser (Auto-Close 4s).
- bridge/aria_bridge.py: empfaengt oauth_callback, POSTet an Brain
  /internal/oauth-callback.
- aria-brain/oauth.py: neuer Manager. Pending-Store mit state+TTL,
  Token-Exchange (Basic-Auth oder Body je nach Provider), persistente
  Speicherung in /shared/config/oauth_tokens.json (mode 0600),
  Token-Refresh wenn <60s Restzeit. Vordefinierte Configs fuer Spotify,
  Google, GitHub, Strava, Microsoft.
- aria-brain/agent.py: META-Tools oauth_authorize / oauth_get_token /
  oauth_revoke.
- aria-brain/prompts.py: System-Prompt-Block zeigt ARIA die feste
  Callback-URL als Quelle der Wahrheit + aktuelle Service-States.
- aria-brain/main.py: HTTP-Endpoints /oauth/services, /oauth/apps,
  /oauth/authorize, /oauth/{service}/revoke, /internal/oauth-callback.
- diagnostic: neue Section "OAuth-Apps". Pro Service Karte mit Status,
  client_id + client_secret (Passwort-Toggle), Speichern + Autorisieren-
  Buttons. Authorize oeffnet Provider-Auth in neuem Tab.
- docker-compose.yml: brain-env um RVS_HOST + RVS_PORT_PUBLIC + RVS_TLS
  ergaenzt (Brain braucht die Werte zum Bau der Callback-URL).
- .env.example: RVS_PORT_PUBLIC + Brain-Timeout-Vars (PROXY_TIMEOUT_SEC
  + Connect/Write/Pool) dokumentiert.
- README.md: OAuth-Pipeline + ARIA-Live-Mirror in Diagnostic-Section,
  OAuth-Apps in Einstellungen-Tab erwaehnt.
- issue.md: OAuth-Pipeline + Brain-Timeout-Fix als erledigt dokumentiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:39:54 +02:00
duffyduckandClaude Opus 4.7 0887674497 fix(brain): Proxy-Timeout 20min -> 24h Read, split httpx-Timeouts, Cleanup-Pfade
Brain timed bei langen Pentests nach exakt 20:00 min raus, obwohl ARIAs
Subprozess fleissig weiterarbeitete und der Live-View alles zeigte.
Root-Cause: proxy_client.py hatte einen 1200s httpx.Client-Timeout —
genau der Wert, den wir vor 5 Tagen am Proxy auf 24h hochgezogen hatten.
Schicht uebersehen.

- docker-compose.yml: PROXY_TIMEOUT_SEC=86400 als brain-env.
- proxy_client.py: httpx.Timeout split (connect=10, read=86400, write=30,
  pool=10). Toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen
  24h laufen.
- routes.js handleNonStreamingResponse: res.on("close") + isComplete-Flag.
  Brain-Disconnect killt jetzt den Subprozess statt ihn verwaisen zu lassen.
- agent.py chat(): try/except — bei Exception nach dem User-Turn wird ein
  Assistant-Error-Marker geschrieben, damit Conversation user->assistant
  konsistent bleibt (kein Tool-Call-Loop-Fail in Folge-Calls).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:24:22 +02:00
duffyduckandClaude Opus 4.7 f5243b1abb fix(proxy): Idle-Watchdog statt Hard-Timeout fuer lange Agent-Sessions
Pentests u.ae. brauchen oft >20min — der bisherige 20-min Hard-Cutoff
in claude-max-api-proxy's subprocess/manager.js killte den Subprocess
mitten in der Arbeit, egal wie aktiv ARIA gerade war.

Loesung:
- Hard-Timeout via sed auf 24h hochgesetzt (Last-Resort gegen wirklich
  haengende Subprozesse).
- Eigener Idle-Watchdog in routes.js: Subprocess wird gekillt erst wenn
  ueber ARIA_IDLE_TIMEOUT_MS (Default 20min) keine message/content_delta
  Events ankommen. Jede Aktivitaet resettet den Timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:02:04 +02:00
duffyduckandClaude Opus 4.7 eb5c178139 fix(proxy): tool_result Events ueber generic 'message' statt nicht-existentem 'user'
Der claude-max-api-proxy Subprocess-Manager emittiert nur 'message',
'assistant', 'content_delta', 'result', 'error', 'close', 'raw' —
KEIN 'user'. tool_result-Blocks landen daher ausschliesslich im
generischen 'message'-Event mit type==='user'. Filter darauf statt
auf einen Event-Namen der nicht existiert, sonst kam in der ARIA-Live-
View nichts an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:56:17 +02:00
duffyduckandClaude Opus 4.7 31b0bfaac1 feat(diagnostic): ARIA-Live (read-only Terminal-Mirror) + Not-Aus statt SSH-Tab
SSH-Tab raus — funktionierte eh nicht zuverlaessig und war konzeptionell
falsch. Stattdessen Live-Mirror der Claude-Code-Session:

- proxy-patches/routes.js: assistant + user Events parsed → POSTed Tool-
  Inputs (truncated 2 KB) + Tool-Results (truncated 4 KB) + Assistant-Text
  an aria-bridge:8090/internal/agent-stream. start/end Marker pro Session.
  Subprocess-Tracking (_activeSubprocesses Map) + interner Side-Channel
  auf Port 3457 mit POST /cancel-all fuer Hard-Kill.

- bridge: neuer /internal/agent-stream Endpoint pusht 1:1 als RVS
  agent_stream. cancel_request Handler nimmt optional 'hard'-Flag —
  triggert dann zusaetzlich _cancel_proxy_subprocesses() das den Proxy-
  Side-Channel ruft.

- rvs: agent_stream whitelisted.

- diagnostic: SSH-Tab → 'ARIA Live'. Monospace-Stream, farbcodiert
  (text=hell, tool_use=cyan, tool_result=gruen/rot, thinking=gelb-italic),
  Auto-Scroll, max 2000 Zeilen Backlog. Roter  Not-Aus-Button mit
  Confirm → aria_panic_stop action → diagnostic-server broadcastet
  cancel_request mit hard:true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:23:13 +02:00
duffyduckandClaude Opus 4.7 1d3c45fdda fix(flux): Torch 2.5.1 — 2.4 crasht in transformers MoE custom_op-Registrierung
transformers 4.50+ registriert in integrations/moe.py einen torch.library
.custom_op mit String-Forward-References als Type-Annotations. Torch 2.4's
infer_schema kann diese nicht aufloesen ("Parameter input has unsupported
type torch.Tensor"), erst 2.5+ macht typing.get_type_hints() draus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:37:15 +02:00
duffyduckandClaude Opus 4.7 84a59d7b4f fix(flux): Torch 2.4 + torchvision — transformers braucht beides
Aktuelles transformers schaltet PyTorch ab wenn < 2.4
("Disabling PyTorch because PyTorch >= 2.4 is required, found 2.3.1").
Ohne PyTorch laed diffusers das FLUX-Modell nicht. torchvision wird
zusaetzlich von CLIPImageProcessor/SiglipImageProcessor gebraucht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:59:50 +02:00
duffyduck 8ad3e39453 release: bump version to 0.1.6.1 2026-05-16 23:29:54 +02:00
duffyduckandClaude Opus 4.7 afa96b1d44 feat(flux): HF-Token in Diagnostic statt .env
Passwort-Feld in der FLUX-Section, mit Show/Hide-Toggle und kurzem
Hinweis-Link zu den HuggingFace-Schritten (Lizenz-Agree + Token-Erzeugung).
Wert wird in voice_config.json persistiert und per config-Broadcast an
die flux-bridge gepusht; dort vor jedem from_pretrained als HF_TOKEN +
HUGGING_FACE_HUB_TOKEN env gesetzt.

HF_TOKEN aus .env.example + docker-compose.yml entfernt. Auch FLUX_MODEL
aus compose raus — Default-Modell kommt jetzt komplett aus Diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:25:55 +02:00
duffyduckandClaude Opus 4.7 0407c5bc3c chore(diagnostic): FLUX-Einstellungen in eigene Section statt unter Sprachausgabe
Stand vorher in der Sprachausgabe-Card — falscher Ort, weil
Bildgenerierung eigene Domaene ist. Neue settings-section zwischen
Sprachausgabe und Whisper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:21:06 +02:00
duffyduckandClaude Opus 4.7 2d348aeec7 feat(flux): Modell-Wahl per Diagnostic + raw/switch-Keywords + Download-Hinweis
Diagnostic-Einstellungen fuer FLUX:
- Default-Modell (dev | schnell) — wird via RVS gepusht, flux-bridge
  hot-swappt die Pipeline aus dem HF-Cache (~15-30s)
- Raw-Keyword (Default 'flux') — Pipe-Modus, Brain leitet Stefans Text
  1:1 als prompt durch, kein Rewriting/Beautify
- Switch-Keyword (Default 'fix') — zwingt das ANDERE Modell als Default

Brain-Tool flux_generate um model + raw erweitert, System-Prompt-Block
mit den aktuellen Diagnostic-Settings + Whisper-Toleranz-Hinweis.

Kein eager Bootstrap-Load: flux-bridge wartet auf config oder ersten
Request. Bei erstem HF-Download zeigt Banner "laedt erstmalig runter"
mit Pfeil-Icon, Toast in der App wenn fertig.

FLUX_MODEL aus der .env entfernt (Steuerung jetzt komplett ueber
Diagnostic). HF_TOKEN-Kommentar erklaert warum trotz lokaler Inference
noetig (HF Gate-Mechanismus fuer FLUX.1-dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:11:22 +02:00
duffyduckandClaude Opus 4.7 7e53dcfed3 feat(flux): Bildgenerierung via FLUX.1-dev — flux-bridge auf Gamebox
Eigener Compose-Stack im /flux Verzeichnis (kann auf separater Maschine
laufen). aria-bridge routet flux_request via RVS, ARIA referenziert das
fertige PNG im Reply mit [FILE: ...]-Marker. Brain-Tool flux_generate
mit Caps fuer steps/dimension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:33:48 +02:00
duffyduck 33d5be781f release: bump version to 0.1.6.0 2026-05-16 19:21:04 +02:00
duffyduckandClaude Opus 4.7 785f5d0805 fix(bridge): grosse File-Re-Downloads zerreissen nicht mehr die WS
Symptom (aus Bridge-Log): bei chat_history_request triggert die App
file_request fuer alle fehlenden Anhaenge. Bei einem 40 MB MP4 wird das
base64-encoded ~53 MB, ueberschreitet das RVS-maxPayload (50 MB).
Server droppt mit Code 1009 'message too big', Bridge crasht im cleanup
mit AttributeError 'NoneType has no call_soon' (websockets-Lib-Bug bei
nested context-manager-cleanup nach abgerissener Verbindung).

Drei Layer:

(1) RVS-Server: maxPayload 50 → 100 MB — deckt ~70 MB binaer ab nach
    base64-inflate. Comment im server.js erklaert den Hintergrund.

(2) Bridge: max_size 50 → 100 MB synchron zum Server. PLUS pre-check
    im file_request-Handler — Dateien > 70 MB werden mit Fehler-Response
    abgewiesen statt blind base64-zu-encoden und die WS zu killen.
    Limit knapp unter Server-Limit damit Bridge proaktiv blockiert.

(3) App: file_response-Handler liest 'error'-Feld aus dem Payload und
    zeigt nen Toast 'Datei X: Datei zu gross fuer Transfer (40 MB,
    Limit 70 MB)'. Statt einfach zu schweigen oder endlos zu retryen.

Crash bei websockets-cleanup ist ein Lib-Bug (NoneType.call_soon) —
nicht direkt fixbar, aber tritt jetzt nicht mehr auf weil Bridge proaktiv
die zu grossen Files ablehnt und die WS nicht mehr abreisst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:18:52 +02:00
duffyduck fac87474ec release: bump version to 0.1.5.9 2026-05-16 18:41:10 +02:00
duffyduck 8227266aea release: bump version to 0.1.5.8 2026-05-16 18:06:37 +02:00
duffyduck 5d24e01d4b release: bump version to 0.1.5.7 2026-05-16 16:39:35 +02:00
duffyduckandClaude Opus 4.7 4fe72cc4a8 feat(chat): System-Hints in Bubbles ausblenden (Toggle in Settings)
Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
Kontext hat — z.B. '[Stefans aktuelle GPS-Position: 53.0, 8.5. Nutze die
nur wenn ...]' oder '[Hinweis: Stefan hat dich gerade unterbrochen...]'.
Die landeten via chat_backup auch in der App-Bubble — Stefan sieht jeden
Hint mit, hat nichts in der UI verloren.

Fix: App-side stripSystemHints() filtert aufeinanderfolgende `[...]`-
Bloecke am Textanfang inkl. Trennleerzeichen. Wird in renderMessage
angewendet, default an (Hints versteckt). Toggle in Settings →
Allgemein → 'Chat-Bubbles' kehrt's um falls Debug gewuenscht.

Brain bekommt weiterhin den vollen Text — Bridge-Side unveraendert.
Live-Toggle: Settings setzt aria_show_hints in AsyncStorage, ChatScreen
re-liest alle 2s (gleicher Mechanismus wie tts_enabled etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:21:12 +02:00
duffyduckandClaude Opus 4.7 eeeb1d43f5 chore(diagnostic): Gateway-Reste rauswerfen — Spam-Log weg
Diagnostic loggte konstant '[gateway] Nicht verbunden — kann nicht senden'
weil die UI bei jedem Send-Klick noch versuchte ueber den OpenClaw-
Gateway-Pfad zu schicken. Den gibt's seit Monaten nicht mehr — alles
laeuft via Diagnostic → RVS → Bridge → Brain (HTTP).

server.js:
- sendToGateway() loggt nichts mehr (No-Op, returnt false)
- sendToRVS() raeumt den 'gateway + RVS dual'-Pfad weg, geht direkt
  ueber RVS
- 'test_gateway'-Action vom Client wird umgeleitet auf RVS damit alte
  Browser-Sessions noch funktionieren

index.html:
- 'Gateway senden'-Buttons (Chat-Test + Vollbild) entfernt, 'Via RVS
  senden' umbenannt zu 'Senden'
- Gateway-Tab im Log-Viewer raus, mapSourceToTab leitet evtl. Reste
  in den server-Tab um
- testGateway() + testGatewayFS() JS-Funktionen entfernt
- btn-gw-Disable-Logik raus

connectGateway/handleGatewayMessage/gatewayWs/state.gateway im server.js
bleiben als deprecated stehen — kein aktiver Code zugreift mehr drauf,
aber rauswerfen wuerde viele Diffs erzeugen ohne Nutzen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:15:39 +02:00
duffyduckandClaude Opus 4.7 0044e222db fix(phone): Anruf-Erkennung im Hintergrund + bei gesperrtem Display
Symptom: App bekommt im minimierten oder display-gesperrten Zustand
nicht mit ob ein Anruf angefangen oder beendet wurde — TTS spricht
weiter waehrend Telefon klingelt, oder bleibt stumm nach Auflegen.

Zwei Ursachen:

1) Kotlin: TelephonyCallback war auf reactApplicationContext.mainExecutor
   registriert. Wenn die Activity pausiert ist (display aus, App im
   Hintergrund), wird der mainExecutor verzoegert oder gar nicht
   abgearbeitet — Call-State-Events kommen nicht durch.
   Fix: eigener Executors.newSingleThreadExecutor() — laeuft unabhaengig
   vom UI-Thread solange der App-Prozess lebt (Foreground-Service
   garantiert das).

2) TS: TelephonyManager-Listener kann nach laengerer Hintergrund-Zeit
   verloren gehen (React-Bridge-Context recreated nach Resume).
   Fix: neue refresh()-Methode in phoneCallService, AppState-Resume
   ruft sie auf — wenn telephonyAttached=false ist, wird der Native-
   Listener neu attached.

Plus: Status-Property telephonyAttached macht in Logs sichtbar ob
Pfad 1 (TelephonyManager) wirklich greift. Pfad 2 (AudioFocus fuer
VoIP) war nie betroffen, der laeuft komplett im Native-Code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:59:55 +02:00
duffyduckandClaude Opus 4.7 048d231b60 fix(wake): false-positive nach langer Hintergrund-Pause verwerfen
Symptom: Ohr aktiv, App im Hintergrund (jetzt mit Foreground-Service
permanent lebendig), nach laengerer Zeit oeffnet Stefan die App und sie
nimmt schon auf — angeblich Wake-Word getriggert. War aber TV/Husten/
sonstige Hintergrund-Geraeusche waehrend Stefan nicht da war.

Mit dem neuen Hintergrund-Modus laeuft openWakeWord jetzt permanent und
faengt jedes False-Positive im Hintergrund auf. Ohne dieser Fall war
das nicht moeglich weil die JS-Engine pausiert war.

Fix: Heuristik beim AppState-Resume in ChatScreen.tsx
- backgroundDauer wird gemerkt (lastBackgroundAt vs Resume-Zeit)
- Wenn >30s im Hintergrund UND state='conversing' UND letzter Wake-
  Trigger juenger als 15s: false-positive — Aufnahme abbrechen + zurueck
  zu armed
- Resume-Cooldown 1500 → 3000 ms (Audio-Spikes beim AppState-Switch
  haben gelegentlich nach 1.5s noch nicht verklungen)

Neue Methoden:
- wakeword.ts: lastTriggerAt-Tracking + discardIfFreshlyTriggered(maxAge)
- audio.ts: cancelRecording() — bricht recorder ab ohne Result zu
  emittieren, loescht die Audio-Datei

Setzt voraus dass Stefan nicht laenger als 30s im Hintergrund mit ARIA
spricht ueber Wake-Word. Falls doch: bei Resume waere die Aufnahme weg
und er muesste nochmal triggern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:54:07 +02:00
duffyduck 2bac9c26ca release: bump version to 0.1.5.6 2026-05-16 14:32:34 +02:00
duffyduck c758727345 release: bump version to 0.1.5.5 2026-05-16 11:29:45 +02:00
duffyduckandClaude Opus 4.7 cb0e879118 feat(app): Hintergrund-Modus — App laeuft weiter wenn minimiert
Bisher pausierte Android nach ~30s im Hintergrund die JS-Engine.
WebSocket schlief ein, Trigger-Replies vom Brain kamen nicht durch,
Timer-Erinnerungen feuerten in der App nicht obwohl im Brain
ausgeloest. Nach laengerer Hintergrund-Pause warf Android den
Prozess ganz raus → beim Wiedereroeffnen Cold-Start, sah aus wie Crash.

Loesung: Foreground-Service mit persistenter Notification — die ist
ohnehin schon da fuer TTS/Mic-Aktivitaet (`AriaPlaybackService`).
Wir erweitern das Slot-System um einen `background`-Slot der dauerhaft
aktiv ist (Settings-Toggle, default an). Notification zeigt "ARIA aktiv
— Hintergrund-Modus" wenn nichts spezifisches laeuft, escaliert zu
"ARIA spricht/hoert" bei TTS/Mic. Tap → App.

Drei Dateien:
- services/backgroundAudio.ts: 'background' als 4. Slot (niedrigste
  Prio, Fallback-Notification). Bestehende tts/rec/wake unveraendert.
- App.tsx: beim Start `acquireBackgroundAudio('background')` aufrufen
  wenn Settings nicht explizit deaktiviert. Plus POST_NOTIFICATIONS-
  Permission-Request (Android 13+).
- screens/SettingsScreen.tsx: neuer Toggle in Allgemein-Section.
  Plus Hinweis auf Android-Akku-Optimierung-Whitelist falls trotzdem
  was klemmt (manche Hersteller-ROMs killen aggressiv).

AndroidManifest unveraendert — foregroundServiceType="mediaPlayback|
microphone" deckt unseren Use-Case ab (ARIA spielt regelmaessig TTS
ab, was den Type rechtfertigt). Service stoppt sich selbst wenn alle
Slots leer sind, das passiert nur wenn der User in Settings den
Hintergrund-Modus deaktiviert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:27:01 +02:00
duffyduckandClaude Opus 4.7 ce6f5b551e fix(chat): Gedanken-Stream scrollt jetzt + Suche praeziser
(1) Gedanken-Stream Modal: vorheriger Fix mit onStartShouldSetResponder
    war falsch — der View wurde komplett zum Responder, die FlatList drin
    bekam null Touch-Events. Jetzt: outer View ohne Touch-Handling, ein
    separates TouchableOpacity-Element oberhalb des Sheets nur fuer den
    Tap-Outside-Close. Sheet-View ist plain View → FlatList scrollt frei.

(2) Such-Sprung praeziser: drei Verbesserungen
    - MAX_SCROLL_RETRIES 3 → 6: bei weiten Spruengen (Bubble #150 von
      Position 0) braucht FlatList mehrere Iterationen bis die Items in
      der Naehe gemessen sind
    - Pre-Scroll-Offset: Fallback fuer unmeasured Items ist jetzt der
      dynamische Mittel der bisher gemessenen Items (statt Pauschal-150).
      Beim Cold-Start sind nur die untersten 10 gemessen, aber deren
      Mittel ist immer noch eine bessere Schaetzung
    - Render-Pause nach Pre-Scroll 200 → 350 ms: bei weiten Spruengen
      braucht FlatList Zeit die Items zu mounten und onLayout zu feuern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:11:38 +02:00
duffyduck b6a68b7658 release: bump version to 0.1.5.4 2026-05-15 22:51:27 +02:00
duffyduckandClaude Opus 4.7 03edee8881 fix(app): Inbox Scroll-Bug + feat(diagnostic): Trigger-Edit
App-Inbox-Modal:
- ScrollView der Top-Section ('Aus diesem Chat') nestedScrollEnabled=true
- MemoryBrowser darunter in einen flex:1-Wrapper gepackt damit er den
  verbleibenden Platz bekommt — ohne den hat seine FlatList intern
  null Hoehe gehabt und Scroll-Gestures verschluckt.

Diagnostic Trigger-Tab:
- ✎ Bearbeiten-Knopf pro Zeile (neben Aktivieren/Deaktivieren/Loeschen)
- Modal hat jetzt einen Edit-Modus: Type+Name disabled, Save-Button
  zeigt 'Speichern', Modal-Title 'Trigger bearbeiten — <name>'
- Fuer Timer im Edit-Modus ein zusaetzliches Feld 'Feuert am (ISO, UTC)'
  damit man den absoluten Zeitpunkt direkt aendern kann (statt 'in X
  Minuten ab jetzt' das nur fuer Create Sinn macht)
- saveTrigger() unterscheidet jetzt zwischen Create-Modus (POST
  /triggers/timer|watcher) und Edit-Modus (PATCH /triggers/{name})
- openTriggerEdit(name) fuellt das Modal mit Werten aus dem Cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:49:51 +02:00
duffyduckandClaude Opus 4.7 7093ebaf0b feat(app): Trigger-CRUD-Section in Settings + nested-Scroll-Fix
Settings hatte zwei Probleme:

1) Gedächtnis-Liste scrollte nur runter, nicht hoch. Klassisches Android
   nested-Scroll-Problem: aeussere ScrollView + innere FlatList mit
   fixer height:600 = nur eine Richtung wird respektiert.

   Fix: outer ScrollView mit scrollEnabled=false wenn die Section eine
   eigene voll-hoch-scrollende Sub-Liste hat (memory/triggers). Plus
   dynamische Hoehe via useWindowDimensions (winHeight - 220 statt
   hardcoded 600) damit MemoryBrowser sauber den verfuegbaren Platz
   nutzt.

2) Trigger waren bisher nur via Diagnostic-Tab editierbar — keine App-
   side CRUD. Stefan wollte das.

   Neu: TriggerBrowser-Komponente (analog MemoryBrowser-Struktur)
   - Liste aller Trigger mit Filter (alle/aktive/inaktive)
   - Toggle aktiv/inaktiv via Switch direkt in der Zeile
   - Tap oeffnet TriggerEditModal (Nachricht/Condition/fires_at/intervals
     editieren, Loeschen-Knopf mit Confirm)
   - "+ Neu"-Knopf oeffnet TriggerNewModal mit Type-Switch (Watcher/Timer),
     Watcher zeigt Hinweis auf verfuegbare Funktionen + Variablen
   - Live Reload-Button, Meta-Info (fire_count, last_fired_at, ...)

   brainApi um Trigger-Endpoints erweitert: listTriggers, getTrigger,
   createTimer, createWatcher, updateTrigger (patch), deleteTrigger,
   getTriggerConditions, getTriggerLogs. Plus Trigger-Type-Definition.

Settings-Liste hat eine neue Section " Trigger" zwischen Gedaechtnis
und Protokoll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:44:24 +02:00
duffyduckandClaude Opus 4.7 b4923bc221 docs: Such-Praezision, Such-Reihenfolge, GPS-Heartbeat, About-Escape
issue.md — zwei neue Blocks:

'Such-Sprung-Praezision + Such-Reihenfolge':
- Cold-Start-Sprung (itemHeights-Cache via onLayout, initialNumToRender
  hoch)
- Such-Scroll-Endlos-Loop (MAX_SCROLL_RETRIES + setMessages-no-op-skip)
- searchMatchIds aus chatVisibleMessages (kein Treffer in Spezial-Bubbles)
- Reihenfolge neueste zuerst (WhatsApp-analog)

'Misc App-Polish':
- About-Text '—' literal → {'—'} expression block
- GPS-Heartbeat 60 s gegen stationaere-User-Veraltung der Position

README:
- Chat-Such-Zeile um Reihenfolge + onLayout-Cache ergaenzt
- GPS-Tracking-Zeile um Heartbeat ergaenzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:38 +02:00
duffyduck 7a66752655 release: bump version to 0.1.5.3 2026-05-15 22:33:10 +02:00
duffyduckandClaude Opus 4.7 b510ccd93a fix(app): Such-Reihenfolge + About-Escape + GPS-Heartbeat fuer near()
(1) Such-Treffer jetzt neueste zuerst (analog WhatsApp/Telegram). User
    ist visuell unten, der erste Sprung landet meist im Viewport ohne
    weiten Pre-Scroll (= weniger Cold-Start-Fail-Risiko). „Naechster"
    geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit
    FlatList beim ersten Versuch wirklich Zeit zum Rendern hat.

(2) SettingsScreen Ueber-Text: `—` wurde literal gerendert weil
    JSX-Text-Knoten keine JS-String-Escapes interpretieren. Fix:
    `{'—'}` als JS-Expression-Block.

(3) GPS-Tracking sendete nach der initialen Position nichts mehr wenn
    der User stationaer war — `distanceFilter: 30` blockiert
    watchPosition-Updates ohne Bewegung. Nach 5 min (NEAR_MAX_AGE_SEC)
    verwirft das Brain die Position als veraltet → near()-Watcher feuern
    nie. Stefan's DRK-Trigger waren so chronisch tot.

    Fix: zusaetzlich zum watchPosition laeuft ein setInterval(60s)
    Heartbeat der die zuletzt empfangene Position erneut sendet. Kein
    extra GPS-Wakeup — akkufreundlich. Damit bleibt der Brain-State
    frisch auch bei stationaerem User; near() funktioniert sobald der
    User tatsaechlich im Radius ist.

Anmerkung zu Stefan's konkretem Test: er war 1.5–2 km von den DRK-
Triggern entfernt (Radius je 300 m) — selbst mit frischen GPS-Updates
haetten die nicht gefeuert. Der Heartbeat-Fix ist trotzdem noetig
damit Trigger ueberhaupt eine Chance haben wenn er tatsaechlich dort
vorbeifaehrt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:30:51 +02:00
duffyduck bbd51406a9 release: bump version to 0.1.5.2 2026-05-15 21:48:14 +02:00
duffyduckandClaude Opus 4.7 2cd436f6e9 fix(chat): Such-Sprung praezise via Layout-Cache + Filter
Symptom: Suche nach 'cessna' sprang zur Oberhausen-Bubble (~15 Bubbles
daneben), egal welcher Versuch.

Zwei Ursachen:

1) searchMatchIds suchte in `messages` (alle Bubbles inkl. Memory/Skill/
   Trigger-Spezial-Bubbles), aber gescrollt wird in `invertedMessages`
   die diese filtert. Wenn 'cessna' nur in einer Memory-Bubble vorkam,
   war die ID in searchMatchIds aber nicht in invertedMessages →
   findIndex=-1 → kein Scroll, Pre-Scroll-Offset von voriger Aktion
   blieb sichtbar. Fix: searchMatchIds aus chatVisibleMessages.

2) AVG_BUBBLE_HEIGHT=150 als Pauschalschaetzung war zu grob — Voice-
   Bubbles sind ~70 px, lange ARIA-Antworten 400+. Pre-Scroll-Offset
   landete bei langen Listen weit daneben. Fix: itemHeights-Ref-Map
   wird per onLayout in renderMessage gefuettert. Pre-Scroll summiert
   echte gemessene Hoehen (Fallback AVG fuer noch nicht gerenderte) —
   beim zweiten Such-Versuch lernt der Cache, beim ersten klappt's
   schon besser als mit dem Pauschalwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:42:08 +02:00
duffyduck 22adc91c1e release: bump version to 0.1.5.1 2026-05-15 12:12:17 +02:00
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduck 58251b26a2 release: bump version to 0.1.3.1 2026-05-14 13:31:59 +02:00
duffyduckandClaude Opus 4.7 5c10990cbc feat(memory): Notizen-Inbox + Settings-Editor (Etappen 4+5)
Etappe 4 — 🗂️ Notizen-Inbox-Button neben der Lupe:
- Statusleiste hat jetzt zwei Icons: 🗂️ Inbox + 🔍 Suche
- Tap auf Inbox-Icon oeffnet ein Vollbild-Modal mit MemoryBrowser-
  Komponente. User sieht alle Memories aus der DB, kann suchen,
  filtern, neu anlegen, und in den Detail/Edit-Modus springen.

Etappe 5 — Memory-Editor in App-Settings:
- SETTINGS_SECTIONS um Eintrag 🧠 "Gedächtnis" erweitert
- Sektion rendert MemoryBrowser (selbe Komponente wie Inbox) in
  einer 600px-Box — vom Diagnostic-Gehirn-Tab inspiriert, aber
  fuer's Handy optimiert
- Beide Stellen recyclen MemoryBrowser+MemoryDetailModal aus
  Etappe 2/3 — kein doppelter Code

MemoryBrowser (neue Komponente components/MemoryBrowser.tsx):
- Lazy-Load aller Memories via brainApi.listMemories
- Client-side Filter: Volltext-Suche (Title+Content+Category+Tags),
  Type-Dropdown, Pinned/Cold/Alle-Toggle
- "+ Neu" Knopf mit Alert.prompt fuer Titel, automatisch type=fact,
  oeffnet danach den DetailModal zum Editieren des Contents
- Item-Render mit Pinned-Marker, Anhang-Badge 📎N, Type-Label,
  Category, 2-Zeilen-Content-Preview
- Tap auf Item oeffnet MemoryDetailModal → CRUD weiter dort

Damit sind alle 5 Etappen aus Stefans Wunsch-Trio durch:
- Bubble-Header dynamic (Etappe 1, committed gestern)
- Tap-Modal mit Detail (Etappe 2)
- Edit + Anhang-Upload im Modal (Etappe 3)
- Notizen-Inbox-Button (Etappe 4)
- Memory-Editor in Settings (Etappe 5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:29:43 +02:00
duffyduckandClaude Opus 4.7 f71936da86 feat(memory): Tap auf Memory-Bubble oeffnet Detail+Edit-Modal in der App (Etappen 2+3)
Stefans naechste Wunsch-Etappe — komplettes Edit eines Memory-Eintrags
aus der App heraus, inkl. Anhang-Upload, ohne Diagnostic-Browser
auszuklappen.

Backend-Fundament (Phase A):
- Brain bekommt GET /memory/get/{id} fuer Einzel-Lookup mit allen Feldern
- RVS ALLOWED_TYPES um brain_request + brain_response erweitert
- Bridge implementiert generischen RVS-Brain-Proxy:
  payload {requestId, method, path, body|bodyBase64, contentType}
  → ruft Brain-HTTP-API → broadcastet brain_response {requestId,
  status, json|text|base64+contentType}. Damit kann die App
  beliebige Brain-Endpoints ueber RVS adressieren — nicht nur Memory.

App-Service (Phase B):
- services/brainApi.ts: Promise-basierter Client. _send() schickt
  brain_request mit requestId, _ensureListener() filtert die passende
  brain_response. Methoden: getMemory, listMemories, searchText,
  searchSemantic, saveMemory, updateMemory, deleteMemory,
  uploadAttachment (Base64), deleteAttachment, getAttachmentBytes.

App-UI (Phasen C+D):
- components/MemoryDetailModal.tsx: Modal mit zwei Modi.
  - Read: Titel, Type, Category, Tags, voller Content, Anhang-Liste
    (Tap = Bild im Vollbild oder Datei-Info), Stift-Icon → Edit.
  - Edit: Titel/Content/Category/Tags/Pinned editierbar, Save via
    brainApi.updateMemory.
  - DocumentPicker + RNFS.readFile(base64) → uploadAttachment(...).
  - Anhang loeschen, kompletter Memory loeschen (mit Alert-confirm).
- ChatScreen: TouchableOpacity-Wrapper um die memorySaved-Bubble,
  Tap setzt memoryDetailId → Modal oeffnet. Hint im Footer
  "tippen für Details" wenn die Bubble eine ID hat.

Etappen 4 (Notizen-Inbox neben Lupe) + 5 (Memory-Editor in App-
Settings) folgen — diese nutzen die gleiche MemoryDetailModal-
Komponente, sind also schnell aufgesetzt sobald 2+3 verifiziert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:26:02 +02:00
duffyduck 62f394b2aa release: bump version to 0.1.3.0 2026-05-14 13:18:55 +02:00
duffyduckandClaude Opus 4.7 6239037fa7 feat(memory): Bubble-Header zeigt jetzt Aktion (angelegt/geaendert/geloescht)
Etappe 1 von Stefans App-Memory-UX-Wunsch:

Brain agent.py: memory_save Dispatcher pushed jetzt action="created",
memory_update Dispatcher pushed action="updated" mit demselben
memory_saved-Event-Typ. Bridge reicht das action-Feld im Payload mit
durch (in beiden Side-Channel-Pfaden — send_to_core + trigger-fired).

App ChatScreen: ChatMessage.memorySaved.action ('created' | 'updated'
| 'deleted'). Bubble-Header je nach Aktion:
- created → "🧠 ARIA hat etwas gemerkt" (gelb)
- updated → "🧠 ARIA hat eine Notiz geändert" (gelb)
- deleted → "🧠 ARIA hat eine Notiz gelöscht" (rot)

Naechste Etappen folgen (Detail-Modal beim Tap, Edit + Anhang-Upload,
Notizen-Inbox neben Lupe, Memory-Editor in Settings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:44:05 +02:00
duffyduck 4b3f8cded2 release: bump version to 0.1.2.9 2026-05-13 08:55:28 +02:00
duffyduckandClaude Opus 4.7 16ebaa652f feat(brain): memory_search + memory_update Tools — ARIA findet Updates aktiv
Bug-Report von Stefan: er hat im Diagnostic den Baujahr-Memory von
1972 auf 1974 geaendert, ARIA wusste das nicht und beharrte auf 1972
(weil ihr letzter Conversation-Turn noch '1972' enthielt). Sie konnte
auch nicht nachpruefen, sagte selbst: "Qdrant kann ich nicht aktiv
durchsuchen".

Fix: zwei neue Meta-Tools im agent.py.

memory_search(query, mode='text'|'semantic', k=5):
- Volltext oder semantic via store.search_text / store.search
- Liefert Liste mit Titel, ID, Content, Anhaengen
- Tool-Description sagt explizit: "Memory ist Truth ueber dem
  Conversation-Window" — wenn beide unterschiedlich sind, gilt
  Memory. Plus Anker-Anwendungsfaelle: 'schau in deinem Gedaechtnis',
  'ich hab das aktualisiert', 'pruef ob's schon was zum Thema gibt'

memory_update(id, title?, content?, category?, tags?, pinned?):
- Patch existierender Memory per ID (aus memory_search oder Cold-Memory)
- Content-Change triggert Re-Embedding fuer Search, sonst nur
  Payload-Update
- Pushed memory_saved-Event analog zu memory_save (App/Diagnostic
  refreshen)
- Tool-Description empfiehlt explizit Update statt neuem Save bei
  Korrekturen/Ergaenzungen — vermeidet Fragmentierung

Damit kann Stefan jetzt sagen "schau in deinem Gedaechtnis" und ARIA
findet den aktualisierten Eintrag. Plus bei spaeteren Korrekturen
("ach nee, 1974") nutzt ARIA memory_update statt memory_save +
hinterlaesst einen sauberen Eintrag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:23:08 +02:00
duffyduckandClaude Opus 4.7 27c04a2874 docs: README + issue — Memory-Anhaenge mit Vision-Pipeline (Stufen A-E + attach_paths)
issue.md: neuer Block "Memory-Anhaenge mit Vision (Stufe A-E +
attach_paths)" mit den 7 Punkten (Storage-Layer, Backend-Endpoints,
Diagnostic-UI, App-UI, System-Prompt-Integration, Vision via Read-
Tool, attach_paths fuer einarmigen memory_save+attach-Workflow).

README.md: Diagnostic-Gehirn-Tab-Beschreibung um 📎-Anhaenge erweitert,
plus neuer Roadmap-Eintrag "Memory-Anhaenge mit Vision-Pipeline" der
das End-to-End-Erlebnis erklaert (User-Foto → ARIA liest via Read →
extrahiert Kennzeichen/Marken/Texte → speichert als Memory mit Foto-
Anhang → spaetere Detail-Fragen lassen ARIA das Bild nochmal lesen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:16:20 +02:00
duffyduckandClaude Opus 4.7 31a1370050 feat(brain): memory_save mit attach_paths — ARIA haengt Bilder selbst an
Letzter Baustein vor Stefan's End-to-End-Test:

memory_attachments.attach_from_path(memory_id, src_path):
- Kopiert eine bestehende Datei aus /shared/uploads/ oder
  /shared/memory-attachments/ in das Anhang-Verzeichnis der Memory
- Pfadschutz: nur ALLOWED_SOURCE_PREFIXES (/shared/uploads/,
  /shared/memory-attachments/) — kein Zugriff auf Root-FS oder
  SSH-Keys
- Groessen-Limit wie save_attachment (20 MB Default)

agent.py memory_save:
- Neuer optionaler Parameter `attach_paths: List[str]`
- Nach dem upsert: pro Pfad attach_from_path → Payload update mit
  neuen Anhang-Metadaten
- Fehler beim Anhang sind nicht fatal (Memory bleibt gespeichert,
  Hinweis in der Tool-Response)
- Tool-Description deutlich erweitert: expliziter Workflow-Hinweis
  bei Bildern → erst `Read <pfad>` aufrufen (Claude Code Read ist
  multi-modal), Texte/Kennungen/Marken in den content extrahieren,
  dann erst memory_save mit attach_paths. Beispiel-Workflow als
  Pseudocode mit Cessna 172 / Kennung D-EAAA.

End-to-End-Workflow ist jetzt einarmig moeglich:
  User: "Ich hab eine Cessna 172" + Bild im Attachment
  ARIA: Read /shared/uploads/aria_xy.jpg → sieht "Kennung D-EAAA"
  ARIA: memory_save(content="Stefan besitzt eine Cessna 172,
        Kennung D-EAAA, weiss/rot lackiert.",
        attach_paths=["/shared/uploads/aria_xy.jpg"])
  → 🧠-Bubble mit Anhang in der App
  → Spaetere Frage "welche Kennung hat mein Flieger?" liefert via
    Cold-Memory den Eintrag inkl. Kennung aus dem content

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:02 +02:00
duffyduckandClaude Opus 4.7 933dd50367 feat(brain): Stufe E — ARIA sieht Bilder ueber Claude Codes Read-Tool
Wir mussten den Proxy nicht patchen. Claude Code's eingebautes
Read-Tool ist multi-modal-faehig — uebergibt man eine Bilddatei,
geht die durch das gleiche Vision-Modell wie via Anthropic-Vision-API.
ARIA hat eh "Tool-Freigaben — Vollzugriff" pinned (inkl. Read), also
muss sie nur wissen dass sie das nutzen darf.

prompts._attachments_line erweitert: bei image/* im Anhang haengen
wir den Hinweis an "Bilder kannst du via `Read <pfad>` direkt ansehen".
ARIA ruft dann selbststaendig Read mit dem Memory-Anhang-Pfad, sieht
das Bild und kann antworten was drauf ist.

Heisst: Stefan sagt "schau dir mein Cessna-Foto an" → ARIA findet
Memory via Cold-Search → sieht die Read-Anweisung → ruft Read auf →
Vision-Modell beschreibt das Bild → ARIA antwortet im Chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:49:09 +02:00
duffyduckandClaude Opus 4.7 d5531521fa feat(memory): Anhaenge in App-Bubble + System-Prompt (Stufe C + D)
Stufe C — App:
- ChatMessage.memorySaved.attachments [{name, mime, size, path, localUri}]
- memory_saved-Listener uebernimmt payload.attachments
- renderMessage memorySaved-Bubble zeigt Anhaenge als Tap-Reihen
  (Icon 🖼/📄 + Filename + Hint). Tap → file_request via Bridge,
  beim ersten Mal "(tippen zum Laden)" → nach file_response cached
  + bei Bildern setFullscreenImage, bei anderen openFileWithIntent
- file_response-Handler updated zusaetzlich memorySaved.attachments
  per serverPath-Match
- Styles fuer memoryAttachmentRow/Icon/Name/Meta

Stufe D — System-Prompt:
- prompts._attachments_line: pro Memory eine Zeile
  "📎 Anhaenge: foo.jpg (image/jpeg, 109 KB) — Pfad: /shared/memory-attachments/<id>/"
- Wird in build_hot_memory_section + build_cold_memory_section
  nach dem Content angehangen
- ARIA "weiss" damit dass Anhaenge da sind und kann via Bash darauf
  zugreifen (file, head, base64 …). Echt sehen kann sie sie erst mit
  Multi-Modal-Pipeline (Stufe E)
- memory_save Dispatcher: attachments-Liste auch im memory_saved-Event
  (vermutlich [] beim Save, aber konsistent fuer spaeteres
  Speichern-mit-Anhaengen-Pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:45:51 +02:00
duffyduckandClaude Opus 4.7 de9b7b46f9 feat(diag): Memory-Anhaenge in der UI (Stufe B)
Diagnostic-Gehirn-Tab kann jetzt Bilder/Dateien an Memory-Eintraege
haengen — drag+drop ueber den File-Input im Memory-Modal.

Memory-Modal (Edit-Modus):
- Neuer Block "📎 Anhaenge" unter Pinned-Checkbox, nur sichtbar wenn
  Memory eine ID hat (Edit). Bei "Neue Memory" stattdessen Hinweis
  "Anhaenge nach Speichern hinzufuegbar".
- "⬆ Datei waehlen" oeffnet File-Picker (multiple), Upload via
  multipart/form-data POST an /memory/{id}/attachments/upload.
- Liste zeigt pro Anhang: Thumbnail (Bilder) oder 📄-Icon,
  Filename, Mime + Groesse, 🗑 Loeschen-Button.
- Bild-Thumbnails sind klickbar → openLightbox.
- Status-Zeile zeigt Upload-Progress + Erfolgsmeldung.

Memory-Liste:
- 📎N-Badge erscheint hinter dem Titel wenn N > 0 Anhaenge da sind.

Diagnostic-Server:
- Brain-Reverse-Proxy-Timeout dynamisch: 120s fuer /attachments-Routen
  (Upload), 60s sonst (vorher pauschal 30s — zu wenig fuer chat/distill).
- multipart-Body wird ueber req.pipe(proxyReq) durchgereicht (FastAPI
  liest File via UploadFile, Content-Type-Header bleibt erhalten).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:36:51 +02:00
duffyduckandClaude Opus 4.7 da4e970a31 feat(brain): Memory-Anhaenge — multipart/form-data Endpoint daneben Base64
Stefan's Test scheiterte: ein normales Handy-Foto als Base64 in der
curl-d-Argumentliste sprengt Bash's ARG_MAX (typisch 128KB-2MB). Plus:
Browser-FormData und curl -F sind eh der Standard fuer File-Uploads.

Fix: zusaetzlicher Endpoint
  POST /memory/{id}/attachments/upload  (multipart/form-data, field: file)

Beispiel auf der VM:
  curl -F file=@/pfad/zu/foto.jpg \
       "$ARIA_BRAIN_URL/memory/<id>/attachments/upload" | jq

Base64-Endpoint (/memory/{id}/attachments) bleibt fuer kleine
Uploads + interne JSON-Tools. Beide rufen am Ende den gleichen
_commit_attachment_meta-Helper, der das Memory-Payload um den
neuen Anhang updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:32:24 +02:00
duffyduckandClaude Opus 4.7 c677cfed24 feat(brain): Anhaenge an Memory-Eintraege (Stufe A — Backend)
Pro Memory koennen jetzt Dateien (Bilder, PDFs, Sound, ...) angehaengt
werden. Use-Case: Stefan sagt "ich hab eine Cessna 172" und pinnt
gleich ein Foto dran — ARIA sieht spaeter neben dem Memory auch die
visuelle Referenz (Stufe E = Multi-Modal-Pipeline).

Stufe A baut nur den Backend-Layer; UI kommt in Stufe B (Diagnostic)
und C (App). Anhaenge werden in Stufe A nur via HTTP-API gepflegt
(curl), ARIA selbst kann sie noch nicht hochladen — sinnvoll erst
wenn die Vision-Pipeline (Stufe E) steht.

Komponenten:

- memory_attachments.py: neuer Storage-Helper. Layout
  /shared/memory-attachments/<memory-id>/<safe-filename>.
  Filename-Sanitization (kein Path-Traversal), Limit 20 MB
  konfigurierbar, save/list/delete/read_bytes + delete_all fuer
  Cleanup beim Memory-Delete.

- vector_store.py: MemoryPoint.attachments (List[dict]) — Metadaten
  {name, mime, size, path} im Qdrant-Payload damit Suche/Anzeige
  sie ohne Filesystem-Lookup kennt.

- main.py:
  - MemoryIn akzeptiert attachments-Liste (fuer Restore-Faelle)
  - MemoryOut liefert attachments
  - GET    /memory/{id}/attachments              → Liste vom FS
  - POST   /memory/{id}/attachments              → Base64-Upload,
            schreibt FS + updated Payload-Liste
  - DELETE /memory/{id}/attachments/{filename}   → FS + Payload-Eintrag weg
  - GET    /memory/{id}/attachments/{filename}   → Bytes mit MIME serve
  - /memory/delete cleanup: ruft attachments.delete_all damit kein
    Verzeichnis verwaist

Smoke-Test nach Brain-Rebuild (Stefan auf VM):
  # Memory-ID rauspicken
  ID=$(curl -s "$ARIA_BRAIN_URL/memory/list?type=fact" | python3 -c "import sys,json;print(json.load(sys.stdin)[0]['id'])")
  # Bild als Base64 hochladen
  B64=$(base64 -w0 /pfad/zu/foto.jpg)
  curl -s -X POST "$ARIA_BRAIN_URL/memory/$ID/attachments" \
    -H 'Content-Type: application/json' \
    -d "{\"name\":\"foto.jpg\",\"data_base64\":\"$B64\"}" | jq
  # Liste anzeigen
  curl -s "$ARIA_BRAIN_URL/memory/$ID/attachments" | jq
  # Datei wieder laden
  curl -s "$ARIA_BRAIN_URL/memory/$ID/attachments/foto.jpg" -o /tmp/back.jpg

Stufe B (Diagnostic-UI) folgt sobald A getestet ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:27:02 +02:00
duffyduckandClaude Opus 4.7 331c1437be docs: README + issue — DB-Truth-Block + memory_save + Search-Modi + Muelltonne dokumentiert
Was alles seit dem letzten Doc-Update dazukam:

issue.md (Bugfixes):
- Cold Memory Crosstalk durch Score-Threshold
- Pinned-/Type-Filter bei aktiver Suche
- Memory-Liste refresh nach Delete
- Thinking-Indikator im RVS-Chat wieder sichtbar
- Memory-Suche filtert Rauschen (score_threshold am Endpoint)
- Cessna-Phantom-Wissen aus System-Prompt raus
- Claude-Code-Auto-Memory abgeklemmt (tmpfs)

issue.md (Features):
- Neuer Block "Memory-System (Phase B Punkt 5+ Bonus)" mit
  memory_save Tool, Volltext-Suche, Advanced Search, Muelltonne,
  Druckansicht, klappbare Kategorien
- Neuer Block "DB als Single Source of Truth" mit brain-import als
  Drop-Folder, DB-Cleanup 60→31, .claude/aria-vm.env Setup

README.md:
- aria-data/brain-import Tabelle-Beschreibung aktualisiert
- .claude/aria-vm.env als neue Zeile in der Konfig-Tabelle
- Diagnostic Gehirn-Tab Beschreibung ausgebaut (Wortlich/Semantisch,
  Advanced Search, klappbare Kategorien, Druckansicht)
- App-Features: Muelltonne pro Bubble erklaert
- Roadmap-Eintrag "Single Source of Truth — Qdrant" als zentrales
  Abschluss-Item nach Tool-Use-Patch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:23:09 +02:00
duffyduckandClaude Opus 4.7 1e754910ee fix(brain): Cold Memory mit Score-Threshold — kein Crosstalk mehr
Bug: Agent.chat() rief store.search() OHNE score_threshold — die
Top-5 wurden ungefiltert in den 'Moeglicherweise relevant'-Block
des System-Prompts gepackt. Bei kleiner DB hatte das absurde Folgen:
Stefan fragte 'hab ich ein flugzeug?', Cold-Search lieferte Top-1
'Watcher-Latenzproblem' mit Score 0.138 + 'Firmenadresse' mit 0.094,
ARIA wob die Firmenadresse in die Antwort ein ('Die Adresse habe ich
aus meinem Gedaechtnis...') — obwohl der User gar nicht danach gefragt
hat.

Fix: Konstante COLD_SCORE_THRESHOLD=0.30 in Agent eingefuehrt und an
store.search() durchgereicht. Treffer unter 0.30 werden als Rauschen
verworfen, ARIA bekommt nur substantielle Memories ins Cold-Set.
Konsistent mit dem Threshold im /memory/search HTTP-Endpoint und dem
Diagnostic UI.

MiniLM-multilingual gibt fuer unverwandte deutsche Texte gerne 0.10-
0.25 Score — alles darunter ist Embedder-Noise, kein echter Bezug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:13:32 +02:00
duffyduckandClaude Opus 4.7 351c58e88e fix(diag): zwei Bugs — Memory-Liste nach Delete + Thinking-Indikator im RVS-Chat
Bug 1: Memory loeschen + Liste zeigt geloeschten Eintrag weiter
  deleteMemory rief loadBrainMemoryList — die fiel bei aktiver Such-
  Ansicht in den Cache-Pfad und renderte den geloeschten Eintrag aus
  brainMemoryCache/brainSearchIds wieder. Fix: nach Delete den Cache-
  Eintrag + brainSearchIds bereinigen und bei aktiver Suche re-search
  ausfuehren (single oder advanced), sonst Vollliste vom Server.

Bug 2: "ARIA denkt..."-Indikator erscheint nicht mehr im Chat-Fenster
  Diagnostic-Server hatte fuer RVS-eingehende agent_activity-Events
  keinen Relay an die Browser-Clients. Bridge sendet die Events brav,
  Diagnostic schluckt sie still. Fix: agent_activity vom RVS an
  Browser broadcasten (mit dem gleichen settled-window-Schutz wie
  beim alten Gateway-Pfad — Trailing-Events nach chat:final werden
  weiter ignoriert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:55:08 +02:00
duffyduckandClaude Opus 4.7 df60bb6d74 fix(brain): Cessna-Beispiel aus System-Prompt raus — keine Phantom-Wissens-Hinweise
ARIA hatte beim 'weisst du ob ich ein Flugzeug habe?'-Test richtig
geantwortet ('nein'), aber transparent erklaert dass sie das Wort
'Cessna' aus dem memory_save Tool-Description kennt — wo es als
Beispiel fuer den fact-Type stand. Ein Beispiel-Text der jedes
Chat-Turn im System-Prompt landet ist suboptimal, auch wenn ARIA
ihn korrekt einordnet.

Fix: das konkrete Beispiel durch eine generische Aufzaehlung
ersetzt (Vorlieben/Besitz/Orte/Termine/Personen). Ohne Stefan-
spezifisches Phantom-Wissen. Selber Spirit in der search-text
Docstring im main.py (geht zwar nicht in den Prompt, aber lieber
konsistent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:39:54 +02:00
duffyduckandClaude Opus 4.7 24cf40293a fix(diag): Pinned-/Type-Filter wirkt jetzt auch bei aktiver Suche
Bug: runBrainSearch und runAdvancedSearch ignorierten den
brain-filter-pinned Dropdown — egal ob "Nur Pinned" oder "Nur Cold"
gewaehlt war, kam immer alles was die Such-Kriterien erfuellte.
Plus: Dropdown-onchange rief loadBrainMemoryList und brach damit
die Suche ab statt sie mit dem neuen Filter neu auszufuehren.

Fix:
- Neue Helfer brainSearchActive() (erkennt single/advanced/none) und
  applyPinnedFilter() (client-side Filter nach 'all'/'pinned'/'cold').
- runBrainSearch + runAdvancedSearch wenden applyPinnedFilter nach
  dem Backend-Hit an. Info-Box zeigt zusaetzlich an wenn
  Pinned-Filter aktiv war ("... · 📌 nur pinned"), bei 0 Treffern
  auch der unfiltered Count fuer Debug ("X Treffer ohne Pinned-Filter").
- Type+Pinned-Dropdowns onchange → onBrainFiltersChanged: bei
  aktiver Suche re-search, sonst loadBrainMemoryList.

Backend bleibt unveraendert (include_pinned all-or-none reicht —
Feinheit "nur pinned" macht der Client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:31:17 +02:00
duffyduckandClaude Opus 4.7 5f96ace469 feat(brain): memory_save Tool — ARIA schreibt selber in die Qdrant-DB
ARIA hatte bisher KEIN Tool um eigene Notizen sauber zu persistieren —
sie ist deshalb aufs Claude-Code-File-Memory ausgewichen (das wir mit
dem letzten Commit per tmpfs abgeklemmt haben). Jetzt schliesst sich
der Loop: ein echtes memory_save-Tool gegen die Qdrant-DB.

Brain:
- agent.py: memory_save als Meta-Tool mit Schema (title, content,
  type, optional category/tags/pinned). Tool-Description erklaert
  die Type-Wahl (identity/rule/preference/tool/skill = pinned,
  fact/conversation/reminder = cold) und sagt explizit: "Du hast
  KEIN File-Memory mehr, schreibe nicht in ~/.claude/projects/..."
- Dispatcher: validiert type-enum, ruft self.embedder.embed +
  self.store.upsert, pushed memory_saved als _pending_events damit
  Bridge eine Bubble broadcasten kann.

Side-Channel-Pipeline (gleich wie skill_created/trigger_created):
- Bridge send_to_core + _handle_trigger_fired: forwarden
  memory_saved als RVS-Event
- rvs/server.js: ALLOWED_TYPES += memory_saved
- diagnostic/server.js: relayed memory_saved von RVS an Browser
- diagnostic UI: addMemorySavedBubble (gelber Border) + Auto-Refresh
  des Gehirn-Tabs wenn aktiv
- android: ChatMessage.memorySaved-Feld, Listener fuer memory_saved,
  renderMessage-Spezialbubble, History-Replace-Schutz (lokal-only)

Damit ist die Architektur konsistent:
  "merk dir X" → ARIA ruft memory_save → Eintrag in Qdrant →
  Diagnostic-Gehirn-Tab zeigt's sofort → bei naechstem Turn liefert
  Cold Memory (Semantic Search) das Wissen wieder rein.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:27:20 +02:00
duffyduckandClaude Opus 4.7 9dd95709b9 fix(proxy): Claude-Code-Auto-Memory abklemmen — tmpfs ueber ~/.claude/projects
Claude Code CLI (im Proxy-Container) hat ein eingebautes Auto-Memory-
Feature das Markdown-Files in ~/.claude/projects/<project>/memory/
schreibt. Weil das CLI als ARIAs LLM laeuft, hat sie da ueber Wochen
ihre eigene Schatten-Wissensbasis aufgebaut (cessna, persoenlichkeit,
projects) — komplett parallel zu unserer Qdrant-DB. Genau die doppelte
Truth-Source die wir vermeiden wollten.

Fix: tmpfs ueber das projects/-Verzeichnis im Proxy-Container.
Effekt:
- Claude Code sieht beim Spawn ein leeres projects/ — keine Auto-
  Memory-Files werden geladen
- Schreibt sie was rein, landet's nur im Container-RAM
- Beim Container-Recreate ist alles weg
- Stefans persoenlicher ~/.claude/projects/ auf der VM bleibt
  unangetastet (Volume ist immer noch gemountet, nur das Subdir
  wird ueberlagert)

Migration auf der VM (Stefan einmalig):
  rm -rf ~/.claude/projects/-/memory/
  docker compose up -d --force-recreate proxy

Auto-Memory ist damit deaktiviert. Naechster Schritt (5): ARIA bekommt
einen eigenen memory_save Tool damit sie Sachen sauber in Qdrant
ablegen kann statt aufs File-Memory auszuweichen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:17:21 +02:00
duffyduckandClaude Opus 4.7 a2dee3164a feat(diag): Advanced Search — dynamisch Felder hinzufuegen mit + Button
Statt fest 3 Felder gibt's jetzt eine erweiterbare Reihen-Liste:
- "+ Feld"-Button fuegt eine Reihe hinzu (UND/ODER + Eingabe)
- ✕-Button pro Reihe (ausser der ersten) entfernt sie
- Erste Reihe ist immer "Start" ohne Operator
- syncAdvancedRowsFromDOM rettet Eingaben vor jedem Re-Render
- runAdvancedSearch iteriert ueber alle Reihen mit Inhalt, leere
  werden ignoriert

Damit ist die Boolean-Suche so lang wie noetig — Stefan kann auch
5-6 Begriffe verknuepfen ohne UI-Hack. Min. 1 Feld bleibt immer
(clearAdvancedSearch reseted auf eine leere Start-Reihe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:13:15 +02:00
duffyduckandClaude Opus 4.7 01f0ad3a40 feat(diag): Advanced Search mit AND/OR + mehrere Begriffe
Klappbares Panel unter dem Suchbalken — Stefan kann bis zu 3 Begriffe
eingeben und mit AND/OR verknuepfen, links nach rechts ausgewertet.
Backend bleibt simpel: pro Begriff einmal /memory/search-text aufgerufen,
die Treffer-Set-IDs werden client-seitig per AND (intersect) oder OR
(union) kombiniert.

UI:
- "⌃ Erweitert" Button rechts neben ✕ klappt das Panel auf
- 3 Eingabefelder mit 2 Operator-Dropdowns dazwischen (UND/ODER)
- "Suchen"-Button im Panel
- "Felder leeren" reseted
- Leere Felder werden ignoriert — sind nur 2 belegt, gibt's nur 1 Operator
- Typ-Filter aus dem Hauptbalken wird mit angewandt
- Info-Banner zeigt die kombinierte Suchformel zurueck

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:32:55 +02:00
duffyduckandClaude Opus 4.7 6549fcbce8 feat(brain): Volltext-Suche zusaetzlich zu Semantic — Default ist jetzt Wortlich
Stefan wollte ne richtige Suche statt nur "klingt aehnlich". Beide
Modi sind jetzt verfuegbar, Default ist Volltext:

- 📝 Wortlich (Substring, case-insensitive ueber Title + Content +
  Category + Tags) — neuer Endpoint /memory/search-text. Full-Scan
  via Qdrant scroll, k=50. Findet "cessna" exakt im Content. Bei
  kleiner DB (<1000 Eintraege) unkritisch performant.

- 🧠 Semantisch (Embedder + score_threshold 0.30) — bestehender
  /memory/search Endpoint. Findet konzeptuell verwandte Eintraege.

Diagnostic UI: Dropdown neben dem Suchfeld zum Modus-Wechsel.
Info-Banner zeigt klar welcher Modus aktiv ist.

Warum Wortlich Default: bei kleiner DB liefert Semantic gern False
Positives mit Score 0.30-0.45 fuer komplett unverwandte Begriffe
(z.B. "cessna" matched "Tageslog fuehren" mit 0.43). Wortlich ist
deterministisch und vermeidet das Rauschen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:59:31 +02:00
duffyduck 3c41f11997 release: bump version to 0.1.2.8 2026-05-12 16:45:29 +02:00
duffyduckandClaude Opus 4.7 3f2499b528 feat(chat): Muelltonne pro Bubble — gezielt eine Nachricht loeschen
Stefan kann jetzt einzelne Chat-Bubbles loeschen (mit Rueckfrage).
Die Bubble verschwindet aus chat_backup.jsonl (Bridge), Brain-
Conversation (rolling window + jsonl) und allen Clients (App +
Diagnostic). Genauso wichtig fuer ARIA: der gloeschte Turn ist im
naechsten Chat-Prompt nicht mehr im Window.

Pipeline:
  UI 🗑 + confirm
  → RVS delete_message_request {ts}
  → Bridge._delete_chat_message:
      - chat_backup.jsonl Zeile mit ts entfernen (atomar via tmp+rename)
      - Brain POST /conversation/delete-turn (role+content match)
      - RVS broadcast chat_message_deleted {ts}
  → App + Diagnostic entfernen Bubble lokal per ts-Match

Backend-Aenderungen:
- aria-brain/conversation.py: remove_by_match(role, content, ts_hint)
  + _rewrite_file (atomar). Match nahester Turn bei mehrfach gleichem
  content.
- aria-brain/main.py: POST /conversation/delete-turn (POST statt DELETE
  weil FastAPI keine Bodys auf DELETE erlaubt)
- bridge/aria_bridge.py: HTTP-Listener /internal/delete-chat-message
  + RVS-Handler delete_message_request. _append_chat_backup gibt jetzt
  ts zurueck, _process_core_response packt backupTs ins chat-Event.
- rvs/server.js: ALLOWED_TYPES um delete_message_request +
  chat_message_deleted erweitert.
- diagnostic/server.js: delete_chat_message-Action + chat_message_deleted
  Relay zum Browser.

Frontend-Aenderungen:
- diagnostic/index.html: 🗑 erscheint on-hover in Bubbles mit data-ts,
  confirm()-Dialog, addChat + chat_history setzen data-ts. WS-Listener
  fuer chat_message_deleted entfernt Bubble per data-ts.
- android/ChatScreen.tsx: backupTs in ChatMessage, Muelltonne-Button
  unten rechts in jeder Bubble, Alert-confirm, RVS-Listener fuer
  chat_message_deleted entfernt aus messages-State.

Live-User-Bubbles (sofort gerendert vom eigenen Send) haben noch
keinen backupTs bis der Bridge-Roundtrip durch ist — die Muelltonne
erscheint dort erst nach kurzer Verzoegerung / Reload. Folgekommit
kann das polieren wenn noetig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:42:20 +02:00
duffyduckandClaude Opus 4.7 daf0d44dd7 fix(brain): Memory-Suche filtert jetzt Rauschen — score_threshold + kleineres k
Bug: bei kleiner DB (31 Eintraege) lieferte die Suche fuer JEDES Wort
fast alles als Treffer zurueck — k=20 Top-N ohne Threshold sorgte
dafuer dass auch "banane" zehn vermeintliche Treffer mit Scores
0.09-0.22 (= Rauschen) zurueckgab.

Fix:
- vector_store.search() bekommt optional score_threshold (an Qdrant
  durchgereicht, das nimmt's nativ)
- /memory/search endpoint hat score_threshold-Query-Param (default 0.30)
- Diagnostic schickt k=10 + score_threshold=0.30 statt k=20 ohne Threshold
- "Keine Treffer"-Info-Box wenn alle Treffer < Threshold

MiniLM-multilingual liefert typischerweise:
  >0.50 → starker Treffer
  0.30-0.50 → relevant
  0.20-0.30 → grenzwertig
  <0.20 → Rauschen

Mit score_threshold=0 (oder None) bleibt die alte Top-N-Semantik
fuer Aufrufer die Rauschen explizit wollen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:59:05 +02:00
duffyduckandClaude Opus 4.7 051d629cb3 chore: brain-import/ wird komplett ignoriert (Drop-Folder)
Stefan wirft MDs rein wenn er was migrieren will, klickt im
Diagnostic-Gehirn-Tab auf "Migration aus brain-import/", fertig.
Was nicht migriert ist, liegt halt rum — gehoert aber nicht ins Repo
(private Daten, ephemerer Kram).

.gitignore-Pattern:
  aria-data/brain-import/*
  !.gitkeep
  !README.md

Alte spezifische USER.md-Zeile durch das catch-all ersetzt — wir
mussten USER.md.example und Co. eh nicht mehr im Repo halten.

README in dem Verzeichnis entsprechend angepasst (Drop-Folder, nicht
"leerer Restposten").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:49:03 +02:00
duffyduckandClaude Opus 4.7 1a19b362d7 chore: brain-import/-MDs raus — DB ist Truth, README + .gitkeep statt Saatgut
AGENT.md/BOOTSTRAP.md enthielten Duplikate, OpenClaw-Referenzen und
fast-Memory-Hinweise auf das alte file-basierte System. Nach dem
DB-Cleanup (60 → 31 Eintraege) sind die alten MDs nicht mehr nuetzlich
— Stefan kuratiert direkt im Diagnostic-Gehirn-Tab, Backup laeuft via
Bootstrap-Snapshot (JSON) oder Komplett-tar.gz.

TOOLING.md.example + USER.md.example mit raus (auch obsolet).
.gitkeep haelt das Verzeichnis im Repo, README dokumentiert wofuer
es mal war und wann man es wieder braucht (Disaster-Recovery ohne
Snapshot, neues ARIA von Null).

Migration-Code (aria-brain/migration.py) bleibt — falls jemand mal
frische MDs reinpackt um sie zu parsen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:45:50 +02:00
duffyduckandClaude Opus 4.7 6ebee21bf0 chore(claude): .claude/*.env gitignored — .example als Vorlage commited
Damit kann die Dev-Maschine (wo Claude Code laeuft) die aria-wohnung-VM
ueber Diagnostic Port 3001 erreichen, ohne die interne IP im Git zu
haben. Pro Maschine wird .claude/aria-vm.env aus dem .example kopiert
und mit der lokalen Routing-Info gefuellt.

Nutzung:
  source .claude/aria-vm.env
  curl -s "$ARIA_BRAIN_URL/memory/stats"

Im docker-compose-Netz aria-net leben die Hostnamen (aria-brain etc.)
weiterhin direkt — das brauchst nur Hosts AUSSERHALB der VM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:31:32 +02:00
duffyduckandClaude Opus 4.7 3e35c0853b ux(diag): Gehirn-Kategorien standardmaessig eingeklappt
Beim ersten Aufruf (kein localStorage-Eintrag) sind alle Type-Sections
collapsed. Stefan klappt gezielt auf was er sehen will, statt eine
Wand of Text zu sehen. Sobald er einmal getoggelt hat, ueberschreibt
sein persistiertes State den Default — also nicht aufdringlich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:15:22 +02:00
duffyduckandClaude Opus 4.7 39eec25828 feat(diag): Memory-Druckansicht — Strg+P → als PDF
Neuer Button "📄 Drucken / PDF" im Gehirn-Tab oeffnet eine sauber
formatierte Print-View in neuem Tab. Druck-CSS optimiert (page-break-
inside:avoid pro Entry, schwarze Borders fuer Print, Action-Bar wird
versteckt). Aktueller Type+Pinned-Filter wird respektiert.

Browser-eigenes "Als PDF speichern" greift dann — kein Tool noetig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:06:45 +02:00
duffyduckandClaude Opus 4.7 517bc7ca8e feat(diag): Gehirn-Tab — klappbare Type-Header + Category-AutoSuggest + Info-Modal
UX im Memory-Browser geschaerft, Stefan-Wunsch:

1. Klappbare Type-Gruppen:
   Jeder Type-Header (Identität, Regeln, ...) hat jetzt einen ▼/▶
   Indikator und reagiert auf Click. Eingeklappte Sektionen werden
   in localStorage gemerkt — bleiben ueber Reloads stabil.

2. Category-AutoSuggest:
   Das Kategorie-Feld im Neu/Edit-Modal hat jetzt ein <datalist>
   mit allen schon in der DB existierenden Categories als Vorschlag.
   Neue Categories sind weiterhin frei eintippbar. Liste wird bei
   jedem renderBrainList-Aufruf aus dem Cache aktualisiert.

3. Info-Button (ℹ) neben dem Typ-Dropdown:
   Erklaert welche Types FEST im System-Prompt eine eigene Sektion
   bekommen (identity/rule/preference/tool/skill — Hot Memory)
   und welche nur via semantischer Cold-Search reinkommen (fact/
   conversation/reminder). Konsistent mit prompts.py:TYPE_HEADINGS.
   Auch dokumentiert dass Category ein freier Tag ist und den
   Prompt nicht direkt beeinflusst.

Type-Dropdown-Labels selbst zeigen jetzt (FEST) / (Cold) als Hinweis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:01:28 +02:00
duffyduckandClaude Opus 4.7 9ea7908fe4 docs: README + issue — Proxy-Tool-Use-Patch + Trigger-Reply-Push dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:52:04 +02:00
duffyduckandClaude Opus 4.7 7237f05344 fix(trigger): Trigger-Antworten landen jetzt im Chat — Brain → Bridge Push
Bug: Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft
er agent.chat() direkt im eigenen Prozess. Die Antwort wurde nur ins
Trigger-Log geschrieben — kein RVS-Broadcast, kein TTS, nichts in
App/Diagnostic sichtbar.

Fix: Bridge ↔ Brain bekommen einen internen HTTP-Push-Kanal.

Bridge (Port 8090, nicht exposed, nur aria-net intern):
  asyncio.start_server-basierter HTTP-Listener.
  POST /internal/trigger-fired
    body: {reply, trigger_name, type, events}
  → _handle_trigger_fired feuert Side-Channel-Events
    (trigger_created/skill_created/location_tracking) erst,
    dann _process_core_response(reply) — exakt der gleiche Pfad
    wie normale Chat-Antworten (Chat-Bubble + TTS + chat_backup).

Brain background.py:
  Nach agent.chat() in _fire wird agent.pop_events() ausgelesen
  und zusammen mit dem Reply via urllib an aria-bridge:8090
  gepostet (run_in_executor damit es den asyncio-Loop nicht
  blockiert). Failures werden geloggt, der Trigger selbst bleibt
  trotzdem als 'fired' markiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:50:32 +02:00
duffyduckandClaude Opus 4.7 e26226f370 feat(proxy): Tool-Use durchreichen — eigene Adapter-Files ueberschreiben npm-Version
Der claude-max-api-proxy ignoriert das OpenAI-tools-Feld komplett:
openai-to-cli.js wandelt nur messages in einen String, manager.js
spawnt 'claude --print' ohne Tools. Claude Code nutzt dann ihre
internen Tools (Bash, etc.) — bei 'Timer in 2min' macht sie ein
'sleep 120' intern und meldet 'erledigt' ohne dass wir je einen
trigger_timer-Call sehen.

Fix: zwei eigene Adapter-Files unter proxy-patches/ die zur
Container-Startzeit ueber die npm-Version kopiert werden:

  openai-to-cli.js:
    - tools-Feld wird als <system>-Block mit Tool-Schemas + klarer
      Anweisung "Antworte <tool_call name=...>{json}</tool_call>"
      in den Prompt injiziert
    - role=tool messages werden als <tool_result>-Blocks eingewoben
      → Claude sieht den ganzen Tool-Use-Loop
    - assistant tool_calls werden als <tool_call>-Bloecke
      re-serialisiert, damit History-Roundtrips funktionieren
    - Multimodal-content (Array von text-Parts) unveraendert
      unterstuetzt (Original-sed-Patch eingebaut)

  cli-to-openai.js:
    - parsed <tool_call name="X">{json}</tool_call> aus result.result
    - liefert OpenAI-konforme tool_calls + finish_reason=tool_calls
    - Pre-Tool-Text bleibt im content erhalten
    - normalizeModelName null-safe (Original-sed-Patch eingebaut)

docker-compose.yml: zwei sed-Patches die jetzt in den Files leben
sind raus, dafuer ein /proxy-patches:ro-Mount + zwei cp-Kommandos.

Smoke-Tests mit Node lokal alle gruen (single + multi tool_calls,
mit/ohne Pre-Text, History-Replay mit tool_result).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:42:05 +02:00
duffyduckandClaude Opus 4.7 0d13118f7e debug(brain): Proxy-Response loggen — finish_reason + raw-msg + tool_calls-Anzahl
Diagnose-Log um Trigger-Hang zu klaeren: warum legt ARIA keinen Timer
an, obwohl trigger_timer als Tool definiert ist? Wir loggen jetzt nach
jedem Proxy-Call:
  - finish_reason
  - alle Keys aus der message
  - tool_calls-Anzahl + content-Laenge
  - die rohe message (truncated 1500 chars)

So sehen wir ob der Proxy tool_calls leer liefert (Proxy schluckt
tools-Feld?), ob Claude ignoriert (Anthropic-Native-Format statt
OpenAI?), oder ob unser Dispatch falsch parsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:34:12 +02:00
duffyduck b1796520b8 release: bump version to 0.1.2.7 2026-05-12 01:23:18 +02:00
duffyduckandClaude Opus 4.7 0ff44d99c4 docs: README + issue — Aktuelle-Zeit-Block + in_seconds dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:22:14 +02:00
duffyduckandClaude Opus 4.7 8c74b3fed8 fix(brain): Timer in 2min funktioniert wieder — Zeit im Prompt + in_seconds-Param
ARIA wusste nicht wieviel Uhr es ist (kein Bash, kein Time-Tool, kein
Timestamp im System-Prompt) und konnte fires_at als ISO-UTC schlicht
nicht ausrechnen. Zwei Fixes:

1. prompts.py: build_time_section() injiziert UTC + lokale Zeit
   (Europa/Berlin Sommer/Winter-Heuristik) als '## Aktuelle Zeit'-Block
   oben in den System-Prompt. Hilft auch beim Einordnen von
   Watcher-Conditions wie hour_of_day == 8.

2. agent.py trigger_timer-Tool: neuer Parameter `in_seconds` als
   Alternative zu fires_at. Bei relativen Angaben ('in 2 Minuten')
   rechnet jetzt der Server den absoluten Timestamp aus — keine
   Rechnerei in der LLM noetig. fires_at bleibt fuer feste Termine.
   required nur noch name + message.

Diagnostic-API (/triggers/timer) bleibt absolute-only, da der Browser
selbst datetime hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:21:23 +02:00
duffyduck c3fefc60c0 release: bump version to 0.1.2.6 2026-05-12 01:08:35 +02:00
duffyduckandClaude Opus 4.7 7107ce4fdd docs: README + issue — Triggers, GPS-Tracking, Bug-Fixes nachgezogen
README: Trigger-Tab in Diagnostic-Tabs-Listen, App-Feature
"GPS-Tracking (kontinuierlich)". issue.md: neuer Block
"Triggers-System (Phase B Punkt 5)" + 8 frische Bug-Fixes
oben (agent_activity-Haenger, Such-Scroll, STT-Bubble-Timing,
Diagnostic Brain-Antworten, Brain-Card-Live-Status, App-Chat-Sync,
Konversation-Reset-Doppelschlag, OpenClaw-Ghost-IDs).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:02:05 +02:00
duffyduckandClaude Opus 4.7 07c761fc72 feat(brain): GPS-Variablen + near()-Helper + erweiterte Condition-Vars
ARIA kann jetzt GPS-basierte Watcher-Trigger anlegen (Blitzer-Warner-Use-Case),
plus erweiterte Time-, System- und Activity-Variablen.

bridge/aria_bridge.py
  _persist_state() schreibt atomar nach /shared/state/<key>.json.
  Bei jedem chat- und audio-Event:
    - location → /shared/state/location.json {lat, lon, ts_unix}
    - last_user_ts → /shared/state/activity.json
  Brain-Watcher lesen das fuer die GPS- und Activity-Variablen.

aria-brain/watcher.py — komplett ueberarbeitet
  Neue Variablen-Sets:
    GPS:       current_lat, current_lon, location_age_sec (-1 = nie gesehen)
    Zeit (+):  minute_of_hour, day_of_month, month, year, is_weekend, unix_timestamp
    System:    ram_free_mb (MemAvailable), cpu_load_1min (loadavg)
    Activity:  last_user_message_ago_sec
    Memory:    pinned_count (zusaetzlich zu memory_count)

  Neue Funktion fuer Conditions:
    near(lat, lon, radius_m)  Haversine-Distanz von current_lat/lon
                              zum Punkt. False wenn keine Position bekannt.

  Parser-Erweiterung:
    ast.Call jetzt erlaubt, ABER nur fuer direkte Funktionsnamen aus der
    Whitelist (_ALLOWED_FUNCTIONS = {"near"}). Keine Attribute-Access,
    keine Keywords, Args nur Constants/Names/UnaryOp.
  Selbsttest blockt korrekt:
    __import__("os")...           → "Funktionsaufruf nur ueber direkten Namen"
    memory_count.__class__         → "Verbotener Ausdruck: Attribute"
    (lambda: 1)()                  → "Funktionsaufruf nur ueber direkten Namen"

aria-brain/main.py
  /triggers/conditions liefert jetzt zusaetzlich {functions:[...]} mit
  Signaturen + Beschreibungen. current-Snapshot filtert callable() raus
  damit JSON serialisierbar bleibt.

aria-brain/prompts.py + agent.py
  build_triggers_section bekommt condition_funcs als 4tes Argument und
  listet die im System-Prompt unter "Verfuegbare Funktionen". Operatoren-
  Hinweis ergaenzt mit Beispielen + Regeln (keine Variablen in Funktions-
  Args, keine Schachtelung).

diagnostic/index.html
  Trigger-Create-Modal: Variablen-Info-Block zeigt jetzt sowohl Variablen
  (mit aktuellen Werten) als auch Funktionen (Signatur + Beschreibung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:52:13 +02:00
duffyduck 6821eaaa38 release: bump version to 0.1.2.5 2026-05-12 00:40:25 +02:00
duffyduckandClaude Opus 4.7 31aa86a2a9 feat(brain+ui+app): Triggers — passive Aufweck-Quellen fuer ARIA
ARIA hatte bisher nur ein "User fragt → Brain antwortet"-Modell. Neu:
Trigger laufen passiv im Hintergrund (kein LLM-Call) und wecken ARIA
nur dann auf wenn ein Event tatsaechlich passiert.

Drei Typen, zwei aktuell implementiert:
  timer   — einmalig zu festem ISO-Timestamp ("erinner mich in 10min")
  watcher — Polling alle N Sek einer Condition, feuert bei True mit Throttle
            (z.B. "disk_free_gb < 5", max 1x/h)
  cron    — Platzhalter fuer spaeter

aria-brain/triggers.py
  CRUD auf /data/triggers/<name>.json + /data/triggers/logs/<name>.jsonl.
  create_timer, create_watcher, mark_fired, list_logs, etc.

aria-brain/watcher.py
  Built-in Condition-Variablen: disk_free_gb, disk_free_pct, uptime_sec,
  hour_of_day, day_of_week, rvs_connected, memory_count.
  Sicherer Condition-Parser via ast — Whitelist auf Vergleich + BoolOp +
  Name + Const. Kein eval, kein exec, keine Builtins.

aria-brain/background.py
  Async Loop laeuft alle 30s, sammelt einmalig Variables, geht durch
  Trigger-Liste, _should_fire-Check (Timer: fires_at vergangen / Watcher:
  check_interval + throttle respektiert + condition true). Fire ruft
  agent.chat(prompt, source="trigger") — ARIA bekommt das wie eine
  Push-Nachricht und antwortet via Bridge → RVS → App.

aria-brain/main.py
  /triggers/list, /{name}, /{name}/logs, /timer, /watcher, PATCH, DELETE,
  /triggers/conditions (Variablen + aktuelle Werte). Lifespan-Handler
  startet den Background-Loop beim Container-Start, stoppt beim Shutdown.

aria-brain/agent.py
  Meta-Tools fuer ARIA: trigger_timer, trigger_watcher, trigger_cancel,
  trigger_list. ARIA legt Trigger via Tool-Call selbst an wenn Stefan das
  wuenscht. Side-Channel-Event 'trigger_created' wird in chat-Response
  mitgeschickt damit App + Diagnostic eine Bubble zeigen.

aria-brain/prompts.py
  Neue System-Prompt-Section: Liste aktiver Triggers + verfuegbare
  Condition-Variablen mit aktuellen Werten + Operatoren-Erklaerung.
  ARIA weiss damit immer was es schon gibt und welche Vars sie nutzen kann.

bridge/aria_bridge.py + rvs/server.js
  trigger_created als neuer RVS-Message-Type, Bridge forwarded das aus
  data.events analog zu skill_created.

diagnostic/index.html
  Neuer Top-Tab "Trigger". Liste mit Type-Badges (⏱ TIMER / 👁 WATCHER),
  Status, Fire-Count, last_fired. Aktivieren/Deaktivieren + Löschen pro
  Trigger. "+ Neu"-Modal mit Type-Dropdown, Timer-Minuten oder
  Watcher-Condition + Vars-Anzeige + Throttle. Info-Modal-Eintrag mit
  Erklaerung. Live-Bubble im Chat wenn ARIA selbst einen anlegt.

android/src/screens/ChatScreen.tsx
  trigger_created RVS-Handler → eigene Bubble (gelber Border, " ARIA
  hat einen Trigger angelegt", Type/Detail/Message/Zeit). ChatMessage
  bekam triggerCreated-Feld. Lokal-only-Schutz beim Server-Sync analog
  zu skill_created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:38:58 +02:00
duffyduckandClaude Opus 4.7 87cb687610 fix(diagnostic): ARIA-Textantworten landen jetzt im Chat (Gateway-Dedup raus)
Symptom: Diagnostic-Chat zeigt nur ARIA-Dateien (file_from_aria), Text-
Antworten kamen nicht an. STT-Eintraege + User-Messages waren sichtbar.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:55:25 +02:00
duffyduck 8491fb2af7 release: bump version to 0.1.2.3 2026-05-11 23:48:00 +02:00
duffyduckandClaude Opus 4.7 f61864282e docs: README + issue.md mit Token-Metrics + Diagnostic-Tabs aktualisiert
README.md
  - Phase B erledigt-Liste um zwei Punkte erweitert:
    Token/Call-Metrics + Multi-Select-Datei-Manager
  - Diagnostic-Tabs in der Installations-Sektion aktualisiert
    (5 Tabs statt 4 — Skills als eigener Tab)
  - Diagnostic-Sektion weiter unten: Tabs-Liste komplett ueberholt,
    Token-Metrics als eigener Bullet in "Was zusaetzlich drin steckt"

issue.md
  - "Token/Call-Metrics" als erledigter Punkt in "Diagnostic/App-Features"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:45:37 +02:00
duffyduckandClaude Opus 4.7 b2f7d6dda2 feat(brain+diagnostic): Token/Call-Metrics mit Subscription-Plan-Tracking
Stefan hat den Max 5x Plan (~\$90-100/Monat), ungefaehres Limit 225 Calls pro
5h-Fenster fuer Sonnet. Damit nicht in eine Tool-Loop-Schleife laufen ohne
es zu merken: kleine Metrics-Pipeline, sichtbar in der Diagnostic.

aria-brain/metrics.py
  Append-only JSONL Logger unter /data/metrics.jsonl. Pro Claude-Call eine
  Zeile {ts, model, in, out} mit Token-Schaetzung (chars/4, Anthropic-
  Heuristik). aggregate(window) zaehlt die letzten N Sekunden.
  Auto-Rotate bei 50k Zeilen → 25k behalten (~70 KB/Monat bei 1k Calls/Tag,
  Cap also weit oben).

aria-brain/proxy_client.py
  chat_full() ruft am Ende metrics.log_call(model, messages_in, reply).
  Failed/exception-Pfade loggen nicht (sonst false positives).

aria-brain/main.py
  GET /metrics/calls → {h1, h5, h24, d30}, jedes Window mit calls,
  tokens_in, tokens_out, by_model.

diagnostic/index.html
  Neue Card "Token / Calls" im Gehirn-Tab. Plan-Dropdown
  (Pro / Max 5x / Max 20x / Custom), localStorage-persistiert. 4 Metric-
  Zellen fuer 1h/5h/24h/30d mit Calls + Tokens. Progress-Bar oben zeigt
  5h-Counter gegen Plan-Limit. Warn-Klassen: gelb bei 80%, rot bei 90%.
  Auto-Refresh alle 30s wenn Gehirn-Tab offen, plus bei Tab-Wechsel.
  Info-Modal erklaert die Limits + dass HTTP-Call != User-Frage (Tool-Use
  kann pro Frage bis zu 8 Calls verursachen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:43:56 +02:00
duffyduckandClaude Opus 4.7 eeedcc4781 docs: README + issue.md — Phase B komplett (Punkte 2/3/4 + drumherum)
README.md
  - Phase A erledigt-Liste erweitert: Info-Buttons, Multi-Select+ZIP, App-Datei-Manager
  - Neue Sektion "Phase B — Brain mit Memory + Loop + Skills" mit allen 3
    Hauptpunkten (Migration / Conversation-Loop / Skills-System) abgehakt
  - Plus drumherum: Sprachmodell-Setting, App-Chat-Sync, Chat-Suche Next/Prev
  - Phase 2: "Erste Skills bauen lassen" — primaerer naechster Schritt

issue.md
  - "Brain — Phase B (komplett)" Sektion oben — strukturiert in
    Infrastruktur / Memory / Conversation-Loop / Skills / Diagnostic+App
  - Alle Phase-B-Punkte abgehakt, mit Detail-Bullets
  - Offen-Liste: Tool-Use-Verifikation (claude-max-api-proxy tools-Param)
    explizit als ToDo, plus "Erste Skills bauen lassen" als Use-Case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:36:50 +02:00
duffyduckandClaude Opus 4.7 5cf8cab5bd feat: App-Chat-Suche mit Next/Prev + Diagnostic Sprachausgabe-Layout
App Chat-Suche umgebaut von Filter zu Highlight+Navigation
  Vorher: searchQuery filtert die FlatList, zeigt nur Treffer.
  Jetzt:  Suche filtert NICHT mehr, alle Nachrichten bleiben sichtbar.
          Treffer wird gelb (FFD60A) umrandet, FlatList scrollt automatisch
          dorthin.
  - Suchleiste: Input + Counter "N/M" + ▲ + ▼ + ✕
  - ▲ / ▼ navigieren chronologisch durch alle Matches (zyklisch)
  - searchMatchIds via useMemo, searchIndex separates State
  - scrollToIndex mit viewPosition: 0.4 (Treffer landet im oberen Drittel)
  - onScrollToIndexFailed Fallback nach 200ms (Layout noch nicht fertig)

Diagnostic Sprachausgabe-Layout
  Export/Import-Buttons wandern aus dem Section-Header in den Details-Block
  neben "Anwenden" (Stefan's Wunsch). Header zeigt nur noch den Titel.
  File-Input bleibt versteckt im Section-Top, wird vom neuen Button-Block
  unten ueber click() getriggert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:35:02 +02:00
duffyduckandClaude Opus 4.7 3ae9e19524 feat: Datei-Manager Multi-Select + Bulk-Download (ZIP) + Bulk-Delete
Diagnostic + App bekommen Mehrfach-Auswahl im Datei-Manager. Mehr als eine
Datei ausgewaehlt → Download als ZIP. Genau eine ausgewaehlt → einzeln.
Bulk-Delete loescht alle markierten in einem Rutsch.

diagnostic/Dockerfile
  zip via apk add — fuer das ZIP-Streaming im /api/files-download-zip.

diagnostic/server.js
  POST /api/files-download-zip   Body: {paths:[...]} → spawnt 'zip -j -q -',
                                  Pipes stdout in Response. Whitelist auf
                                  /shared/uploads/.
  POST /api/files-delete-batch   Body: {paths:[...]} → loescht alle, broadcastet
                                  file_deleted pro Pfad an Browser + RVS.

diagnostic/index.html
  filesSelected Set + Checkbox-UI pro Datei + "Alle markieren". Wenn 2+
  ausgewaehlt: POST an /api/files-download-zip, Browser saugt das als
  Blob runter. Bei 1: normaler Single-Download.

bridge/aria_bridge.py
  file_delete_batch_request    → ruft Diagnostic /api/files-delete-batch,
                                 antwortet mit file_delete_batch_response.
  file_zip_request {paths,reqId} → ruft Diagnostic /api/files-download-zip,
                                   base64-kodiert, capped auf 30 MB,
                                   sendet file_zip_response.

rvs/server.js
  ALLOWED_TYPES: file_delete_batch_request/response, file_zip_request/response.

android/src/screens/SettingsScreen.tsx
  fileManagerSelected Set + Checkbox-UI pro Datei + "Alle markieren"-Zeile
  oben. Bulk-Bar oben mit count, "⬇ ZIP" / "⬇ Download" (je nach Anzahl),
  und "🗑 Löschen". ZIP-Response landet base64 → RNFS in Downloads-Folder
  (aria-files-<timestamp>.zip), Toast mit Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:30:40 +02:00
duffyduckandClaude Opus 4.7 0ec4b00879 feat: App-Chat-Sync — verpasste Nachrichten + chat_cleared Live-Update
Zwei zusammenhaengende Bugs:
  1. App aktualisierte nicht wenn die Diagnostic "Konversation komplett
     zuruecksetzen" gedrueckt hat — die App hatte den lokalen Stand weiter
  2. Nachrichten die kamen waehrend die App offline/geschlossen war,
     wurden nicht nachgeladen

Loesung: chat_backup.jsonl wird wieder geschrieben (Bridge statt Diagnostic,
weil OpenClaw-Code-Pfad tot ist) und dient als Server-Truth fuer App+Diagnostic.

bridge/aria_bridge.py
  _append_chat_backup() schreibt jeden Turn (User + ARIA) als JSONL-Zeile
  in /shared/config/chat_backup.jsonl. Trigger: send_to_core (User) +
  _process_core_response (Assistant, inkl. file-Attachments).

  _read_chat_backup_since(since_ms, limit) liest die Datei, filtert auf
  ts > since_ms, gibt max limit neueste zurueck. Honoriert file_deleted-Marker.

  Neuer RVS-Handler chat_history_request {since, limit?} → antwortet mit
  chat_history_response {messages: [...], since}.

diagnostic/server.js
  /api/chat-history-clear broadcastet jetzt zusaetzlich chat_cleared via
  RVS (sendToRVS_raw), damit App ihre lokale Liste auch leert. Vorher nur
  Browser-Clients via broadcast() — App war aussen vor.

rvs/server.js
  ALLOWED_TYPES um chat_history_request, chat_history_response, chat_cleared.

android/src/screens/ChatScreen.tsx
  - Bei (re)connect: AsyncStorage 'aria_chat_last_sync' lesen → send
    chat_history_request {since}
  - Handler chat_history_response: incoming → ChatMessage[] mappen,
    Attachments aus 'files'-Array rekonstruieren, mergen (Dedup via timestamp),
    lastSync hochziehen
  - Handler chat_cleared: setMessages([]) + AsyncStorage 'chat_messages' +
    'last_sync' weg
  - Bei jeder eingehenden chat-Message: 'aria_chat_last_sync' updaten damit
    Reconnect nicht doppelt nachzieht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:24:52 +02:00
duffyduckandClaude Opus 4.7 b6b4b1b4d9 fix(diagnostic): loadBrainStatus updated jetzt beide Cards (Main + Gehirn)
Symptom: Main-Tab "ARIA BRAIN"-Card blieb auf "Lade..." haengen. Klick auf
"Status pruefen" tat scheinbar nichts.

Ursache: loadBrainStatus() suchte nur brain-status (Gehirn-Tab). Die Card
im Main-Tab hat aber andere IDs (brain-dot + brain-status-short + brain-error),
die wurden nirgends mehr befuellt seit updateState() das nicht mehr macht.

Fix: loadBrainStatus update jetzt BEIDE Anzeigen synchron — kompakte Main-Card
mit Dot/Status/Error UND die ausfuehrliche Gehirn-Tab-Zeile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:07:42 +02:00
duffyduckandClaude Opus 4.7 950a9d009c feat(diagnostic): Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
Reusable Info-Modal-System: kleines (ℹ)-Button neben Ueberschriften, beim
Klick oeffnet ein Modal mit ausfuehrlicher Erklaerung. Fuer die 4 wichtigsten
Brain-Konzepte sind die Texte vor-definiert (INFO_TEXTS dict).

  - Gehirn — Status     online/offline, N Memories, Qdrant-Endpoint
  - Konversation        Rolling Window 50, Schwelle 60, Destillat-Logik,
                        Hinweis warum chat_backup ≠ conversation.jsonl
  - Memories            Hot vs. Cold, alle 8 Typen erklaert, semantische Suche
  - Bootstrap           Die drei Wege (Migration / Snapshot / Komplett-Gehirn)

Plus inline-ℹ-Button neben der "Konversation: N Turns"-Zeile in der
Status-Card, damit man dort wo's relevant ist sofort die Erklaerung findet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:03:47 +02:00
duffyduckandClaude Opus 4.7 693542ef19 fix(diagnostic): Brain-Card live + Runtime-Konfig-Text + Konversation-Reset macht beides
Drei zusammenhaengende Fixes — alle aus der OpenClaw-Umstellung uebrig.

Brain-Card im Main-Tab
  - updateState() schrieb "aria-core entfernt — Brain-Loop in Arbeit" in
    die Card, weil state.gateway noch den alten OpenClaw-Status-Text
    transportiert hat. Brain ist online, sah aber auf der Diagnostic
    aus als waere alles tot.
  - Fix: updateState laesst die Brain-Card jetzt in Ruhe. Daten kommen
    exklusiv von loadBrainStatus() (fetch /api/brain/health).
  - WS-onopen ruft jetzt loadBrainStatus(), plus 15s-Interval fuer
    Live-Refresh.

Runtime-Konfiguration
  - Hinweis-Text erwaehnte noch aria.env + "Bridge-Container neu starten".
    aria.env gibt's nicht mehr, und Brain liest die Config auch.
  - Neu: ".env / Bridge UND Brain lesen sie beim Start — nach Aenderung
    den jeweiligen Container neu starten (Reparatur-Section oben)."

Konversation-Reset
  - Vorher zwei verschiedene Konversations-Stores ohne klare UI:
    chat_backup.jsonl (Diagnostic-Anzeige) und conversation.jsonl
    (Brain Rolling Window). "Konversation leeren" leerte nur Brain,
    die UI zeigte alle Turns weiter → verwirrend.
  - Neu: ein Button "🧹 Konversation komplett zurücksetzen" leert
    beides parallel (Promise.all auf /api/brain/conversation/reset
    + /api/chat-history-clear) plus die lokale Chat-View.
  - Erklaerungstext darunter macht klar was passiert + was bleibt
    (destillierte Facts + Memories in der Vector-DB).
  - Neuer Endpoint /api/chat-history-clear loescht chat_backup.jsonl
    und broadcastet leere chat_history an alle Browser-Clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:59:35 +02:00
duffyduckandClaude Opus 4.7 d12f356ebe fix(diagnostic): JS-Crash beim Laden durch Ghost-IDs aus OpenClaw-Zeit
Die Diagnostic-Seite lud nicht mehr richtig: bei jedem state-Update vom
Server crashte updateState() auf null.className weil 'gw-dot' nicht mehr
existiert (Gateway-Card wurde durch Brain-Card ersetzt). Mehrere weitere
Stellen waren ebenfalls auf nicht mehr existierende Elemente geleitet.

Bereinigt:
  - updateState() nutzt jetzt brain-dot/short/error (null-safe)
  - openCoreTerminal entfernt (aria-core ist raus)
  - closeTermModal null-safe fuer btn-core-term
  - 'core_auth' WS-Event entfernt
  - 'session_restarted' WS-Event entfernt (perms-status-Element war auch raus)
  - 'openclaw_config' WS-Event entfernt
  - rc-compact-after read/write aus loadRuntimeConfig/saveRuntimeConfig raus
    (Compact-After-Messages-Setting wurde mit aria-core entfernt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:53:30 +02:00
duffyduck 7b55d6a91f release: bump version to 0.1.2.2 2026-05-11 22:38:45 +02:00
duffyduckandClaude Opus 4.7 aa077f60e6 fix(diagnostic+brain): Sprachmodell-Einstellung auf runtime.json umgestellt
War kaputt nach OpenClaw-Abriss: handleGetModel/handleSetModel haben gegen
aria-core (dockerExec + node-script in den Container) gearbeitet, der gibt's
nicht mehr.

diagnostic/server.js
  - handleGetModel/handleSetModel lesen/schreiben jetzt brainModel in
    /shared/config/runtime.json
  - RUNTIME_CONFIG_FIELDS um "brainModel" erweitert
  - Tote Variante (findSettingsFile + base64-node-script) komplett raus

aria-brain/proxy_client.py
  - Liest brainModel aus runtime.json beim Container-Start
  - Fallback: BRAIN_MODEL env → "claude-sonnet-4" Default
  - Bei Aenderung in Diagnostic: aria-brain restarten damit's greift
    (Hinweis steht in der UI)

diagnostic/index.html
  - Section "Model" → "Sprachmodell (Brain)"
  - Hinweis-Block mit Default-Erklaerung und Restart-Hinweis
  - Modelle: claude-sonnet-4 (default), claude-opus-4, claude-haiku-4-5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:36:14 +02:00
duffyduckandClaude Opus 4.7 094bd6e4f1 chore: OpenClaw-Reste raus — tools/, docs/tool-permissions, Diagnostic-Permissions-Section
tools/export-jsonl-to-md.js
  CLI-Konverter fuer OpenClaw-Session-JSONL zu Markdown. Mit OpenClaw raus.
  tools/-Ordner ist jetzt leer und auch entfernt.

aria-data/docs/tool-permissions.md
  Geschichts-Dokument zum OpenClaw-Tool-Permission-Bug. Nicht mehr relevant —
  Brain hat eigenes Tool-Use-System (Skills + Meta-Tools), keine granulare
  Permission-Maske. aria-data/docs/ Ordner war leer, mit raus.

diagnostic/index.html
  - "Tool-Berechtigungen"-Section aus den Einstellungen raus. War nur ein
    disabled Info-Toggle aus OpenClaw-Zeit ("Claude darf alle Tools").
    Brain weiss zur Laufzeit selbst welche Tools es hat (META_TOOLS +
    pro aktivem Skill ein dynamisches run_<skill>).
  - .perm-grid / .perm-item / .perm-info CSS-Klassen entfernt (sonst nirgends
    referenziert).

issue.md
  - tools/export-jsonl-to-md.js-Eintrag mit "(mit OpenClaw raus)" markiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:29:59 +02:00
duffyduckandClaude Opus 4.7 5b0b5eeac6 chore: Setup-Scripts auf neue Brain-Architektur, OpenClaw-Tutorial raus
aria-setup.sh
  Von 142 Zeilen auf 50 zurechtgestutzt. Alle OpenClaw-Schritte raus
  (openclaw.json, exec-approvals, Permissions fuer .openclaw, doctor).
  Es bleibt nur der SSH-Key-Setup fuer aria-wohnung — Brain + Proxy
  teilen sich denselben Key via Bind-Mount aria-data/ssh/.

init.sh
  Legt jetzt aria-data/brain/data + aria-data/brain/qdrant an damit die
  Bind-Mounts beim ersten docker compose up nicht ins Leere zeigen.

OpenClaw Tutorial _ MI.pdf
  Geloescht — OpenClaw ist abgerissen, das Tutorial gehoert nicht
  mehr ins Repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:25:32 +02:00
duffyduckandClaude Opus 4.7 96a1f90ac3 docs: README + issue.md auf die neue Brain-Architektur
README.md
  - Architektur-Diagramm: aria-core (OpenClaw) raus, qdrant + brain rein
  - Tabelle der Deployments aktualisiert
  - .env-Beschreibung: minimal, nur Tokens + RVS, Rest lebt in
    runtime.json (gepflegt ueber Diagnostic)
  - aria-setup.sh-Beschreibung: nur noch SSH-Key-Setup
  - Datenverzeichnis-Baum: brain/qdrant + brain/data + brain-import
  - Docker-Volumes-Tabelle neu (Bind-Mounts statt named Volumes)
  - Diagnostic-Tabs (Main, Gehirn, Dateien, Einstellungen) ersetzen
    die alte Feature-Liste
  - Backup/Reset-Sektion: drei Wiederherstellungswege (brain-import
    Migration, Bootstrap-Snapshot, komplettes Gehirn)
  - Container-Restart statt doctor/aria-restart
  - docker-Befehle: brain/qdrant statt aria-core
  - Phase A erledigt-Liste + Phase B Status (Punkte 1-4 erledigt:
    Brain-Skeleton, Migration, Conversation-Loop, Skills-System)

issue.md
  - "Brain (Phase B)"-Sektion ganz oben unter Offen
  - 9 erledigte Refactor-Punkte abgehakt
  - Aria-core-spezifische TODOs entfernt (Compact-Auto, Session-Reset,
    alle-.env-via-Diagnostic war eh schon erledigt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:24:53 +02:00
duffyduckandClaude Opus 4.7 bfa06d78a7 feat(xtts): Voice Export/Import auf der Gamebox
Zwei neue RVS-Handler in der F5-TTS-Bridge:

  xtts_export_voice
    Packt <name>.wav + <name>.txt aus VOICES_DIR als tar.gz in-memory,
    sendet base64-codiert als xtts_voice_exported zurueck. Diagnostic
    baut daraus den Browser-Download.

  xtts_import_voice
    Empfaengt base64 tar.gz mit <name>.wav (+ optional <name>.txt),
    legt sie in VOICES_DIR ab (sanitized name, Path-Traversal-Schutz),
    sendet xtts_voice_imported zurueck. Anschliessend handle_list_voices
    damit App/Diagnostic die neue Stimme sofort sehen.

So koennen Stimmen zwischen mehreren Gameboxen wandern, ohne die
WAV-Files manuell rumzukopieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:24:35 +02:00
duffyduckandClaude Opus 4.7 d16dcd34cc feat(rvs): neue Message-Types fuer Brain-Flow
ALLOWED_TYPES erweitert um:

  - container_restart                   (App/Diagnostic kann gezielt
                                         Bridge/Brain/Qdrant restarten)
  - file_list_request, file_list_response,
    file_delete_request, file_deleted    (Datei-Manager in App + Diagnostic)
  - xtts_export_voice, xtts_voice_exported,
    xtts_import_voice, xtts_voice_imported (Voice-Backup zwischen
                                            Gameboxen via RVS)
  - skill_created                        (Brain → Bridge → App/Diagnostic:
                                          ARIA hat einen Skill angelegt)

Entfernt:

  - doctor_fix, aria_restart, aria_session_reset
    (aria-core ist abgerissen — durch container_restart ersetzt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:24:27 +02:00
duffyduckandClaude Opus 4.7 dc2f4eb6d2 feat(app): Datei-Manager, Skill-Created-Bubble, Zoom rewriten, Repair-Cleanup
Drei groessere Aenderungen in der Android-App.

Datei-Manager (Settings → Dateien)
  - Neuer Eintrag im Settings-Menue → Modal mit Liste
  - Suche + Filter (Alle / Von ARIA / Vom User)
  - Per Eintrag: ARIA/USER-Badge, Groesse, Datum, Loeschen-Button
  - file_list_request via RVS → Bridge → Diagnostic-HTTP → response
  - file_delete_request loescht serverseitig, file_deleted-Event
    aktualisiert ALLE Chat-Bubbles (Attachment.deleted = true mit
    Strikethrough-Name + 🗑️-Icon)

Skill-Created-Bubble
  - Neuer ChatMessage.skillCreated Typ — eigenes Render mit gelbem
    Border, Skill-Name, Beschreibung, Execution-Mode, Active-Status
  - Falls Skill-Setup fehlschlug: ⚠ Setup-Fehler-Zeile direkt in der Bubble
  - Stefan sieht in der Chat-History immer wenn ARIA selbst einen
    Skill angelegt hat — Transparenz statt schweigend im Hintergrund

Pinch-Zoom rewriten (ZoomableImage.tsx)
  - Multi-Touch-Race-Bugs in der alten Variante geloest:
    * Touch-Count jetzt aus e.nativeEvent.touches.length statt
      gestureState.numberActiveTouches (war nicht zuverlaessig)
    * Re-Snapshot bei JEDEM Finger-Wechsel (1↔2) → keine Spruenge mehr
    * Doppel-Tap via onPanResponderRelease + Bewegungs-Cap
    * pointerEvents="none" auf Image-Wrapper → Touches gehen garantiert
      an PanResponder-View
    * collapsable={false} verhindert Android-View-Flattening
  - 2-Finger-Pinch 1x..5x, simultaner Pan via Focal,
    1-Finger-Pan nur wenn gezoomt (>1.02x), Doppel-Tap toggelt 1x↔2.5x

App SettingsScreen Repair-Section
  - aria-core-spezifische Buttons raus: 🔧 Reparieren, 🚨 ARIA hart neu,
    🧹 Konversation komprimieren (OpenClaw ist abgerissen)
  - Stattdessen generischer container_restart fuer aria-bridge/brain/qdrant
  - Repair-Buttons aus der "ARIA denkt..."-Bubble entfernt (nur Abbrechen)

ChatScreen
  - skill_created und file_deleted Handler im RVS-Message-Switch
  - file_list_response (Modal-State liegt in SettingsScreen)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:24:06 +02:00
duffyduckandClaude Opus 4.7 0f9a029269 feat(diagnostic): Gehirn-Tab, Skills-Tab, Datei-Manager, Wipe-All
Komplett-Umbau der Diagnostic auf die neue Brain-Architektur. OpenClaw-spezifische
Sektionen raus (Gateway-Card, Watchdog, OpenClaw-Config, doctor-fix/Restart-Buttons,
Sessions-Verwaltung, Compact-After-Messages), dafuer drei neue Tabs.

Neue Tabs
  - Gehirn   Memory-CRUD (Add/Edit/Delete + Such-Feld + Type/Pinned-Filter),
             Conversation-Status mit Distill-Trigger + Reset,
             Bootstrap & Migration: aus brain-import/ migrieren,
             Bootstrap-Export/Import als JSON (nur pinned),
             komplettes Gehirn als tar.gz exportieren/importieren.
  - Skills   Aufklappbare Liste mit Run-Logs, Aktivieren/Deaktivieren,
             Export pro Skill (tar.gz), globaler Import-Button,
             "von ARIA"-Badge wenn vom Agent selbst angelegt.
  - Dateien  Browser fuer /shared/uploads/ — User+ARIA-Dateien herunterladen
             oder loeschen. Beim Delete: Live-Update der Chat-Bubbles
             (durchgestrichener Pfad, kein Download-Link mehr).

Einstellungen
  - Neue "Reparatur & Restart"-Section mit Container-Restart-Buttons
    (Bridge/Brain/Qdrant) ueber generischen /api/container-restart
  - Komplett-Reset (🗑 ALLES loeschen) — Brain + Qdrant stoppen,
    /shared/config + /shared/voices + /brain/data + /brain/qdrant leeren
  - Sprachausgabe-Header: Voice-Config-Bundle Export/Import (JSON)
  - Voice-Liste: ⬇ pro Stimme + ⬆ Stimme importieren (tar.gz via XTTS-Bridge)

Backend (server.js)
  - connectGateway/Watchdog/checkGatewayHealth: No-Op (aria-core ist raus)
  - handleLoadChatHistory neu — liest jetzt chat_backup.jsonl statt
    OpenClaw-Sessions; respektiert file_deleted-Marker
  - Neue Endpoints: /api/container-restart, /api/wipe-all,
    /api/files-list, /api/files-download, /api/files-delete,
    /api/voice-config-export, /api/voice-config-import,
    /api/brain/* (Reverse-Proxy zum aria-brain:8080)
  - Entfernt: /api/doctor-fix, /api/aria-restart, /api/aria-session-reset,
    /api/sessions-snapshot, handleGetOpenClawConfig

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:23:42 +02:00
duffyduckandClaude Opus 4.7 70d1500096 feat(brain): Phase B — Vector-DB-Memory, Conversation-Loop, Skills, Tool-Use
OpenClaw (aria-core) ist raus, ARIA laeuft jetzt mit eigenem Agent-Framework
im aria-brain Container. Vector-DB-basiertes Gedaechtnis statt Sessions,
eigener Conversation-Loop mit Hot+Cold-Memory + Rolling Window, Tool-Use
fuer Skills, Memory-Destillat-Pipeline.

aria-brain/ (neuer Container)
  - main.py            FastAPI auf 8080, alle Endpoints
  - agent.py           Conversation-Loop mit Tool-Use (skill_create + run_<skill>)
  - conversation.py    Rolling Window, JSONL-Persistenz, Distill-Marker
  - proxy_client.py    httpx-Wrapper zum Claude-Proxy, OpenAI-Format
  - prompts.py         System-Prompt aus Hot+Cold+Skills
  - migration.py       Markdown-Parser fuer brain-import/ → atomare Memories
  - skills.py          Filesystem-Layer fuer /data/skills/<name>/ (Python-only,
                       venv pro Skill, tar.gz Export/Import, Run-Logs)
  - memory/            Embedder (sentence-transformers, multilingual MiniLM)
                       + VectorStore (Qdrant-Wrapper)

docker-compose.yml
  - aria-core (OpenClaw) raus, openclaw-config Volume raus
  - aria-brain Service (FastAPI + Memory)
  - aria-qdrant Service (Vector-DB) mit Bind-Mount aria-data/brain/qdrant/
  - Diagnostic teilt jetzt Netzwerk mit Bridge (vorher: aria-core)
  - Brain bekommt SSH-Mount fuer aria-wohnung + /import fuer brain-import/

bridge/aria_bridge.py
  - send_to_core → HTTP-Call an aria-brain:8080/chat (statt OpenClaw-WS)
  - aria-core-spezifische Handler raus: doctor_fix, aria_restart,
    aria_session_reset, Auto-Compact-Logik, OpenClaw-Handshake
  - Generischer container_restart-Handler (Whitelist Bridge/Brain/Qdrant)
  - Side-Channel-Events aus /chat-Response (z.B. skill_created) werden
    als RVS-Events forwarded
  - file_list_request / file_delete_request → an Diagnostic forwarded
  - Tote OpenClaw-Connection-Logik bleibt im Code als Referenz (nicht aktiv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:23:17 +02:00
duffyduckandClaude Opus 4.7 d0cb7acd10 chore(gitignore): OpenClaw-Reste raus, Brain-Struktur rein
- aria-data/config/*.env + openclaw.env-Whitelist raus (existieren nicht mehr)
- aria-data/config/USER.md → aria-data/brain-import/USER.md (neuer Ort)
- aria-data/brain/ → aria-data/brain/data/ + qdrant/ (Bind-Mounts fuer Brain + Qdrant)
- aria-data/config/diag-state/ ergaenzt (Diagnostic persistenter State)
- aria-data/voices/ raus (Stimmen liegen auf der Gamebox, nicht auf der VM)
- bridge/__pycache__/ raus (vom globalen __pycache__/ schon abgedeckt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:22:49 +02:00
duffyduckandClaude Opus 4.7 0b58feee1e chore(phase-a): aria.env + OpenClaw-specific configs raus, Brain-Backup angelegt
Vorbereitung fuer Bridge-als-Agent (OpenClaw raus). Phase-A-Cleanup:

- aria-data/brain-import/ angelegt — System-Prompt-Files BOOTSTRAP.md
  + AGENT.md + USER.md.example + TOOLING.md.example dort archiviert.
  Werden vom neuen Agent-Framework spaeter importiert.
- aria-data/config/aria.env + .example geloescht (alle Eintraege waren
  tot oder via runtime.json/Hardcode-Default abgedeckt)
- aria-data/config/openclaw.env + openclaw-auth.json geloescht
  (Provider-Config landet im Bridge-Code)
- docker-compose: AGENT/BOOTSTRAP/USER/openclaw.env-Mounts aus aria-core
  raus; aria.env-Mount aus bridge raus; COMPACT_AFTER_MESSAGES env raus
  (kommt jetzt aus runtime.json)
- bridge: CONFIG_PATH-Lesen aus /config/aria.env entfernt, load_config
  nutzt nur noch runtime.json; _compact_after liest compactAfterMessages
  aus runtime.json (Default 140)
- diagnostic: Eingabefeld "Compact nach Messages" in Runtime-Config-UI;
  load/save schreiben/lesen compactAfterMessages
- init.sh: nur noch .env-Bootstrap (Rest landet eh in runtime.json/DB)

ARIA antwortet im Phase-A-Status ohne ihre Persoenlichkeit
(System-Prompt nicht mehr gemountet, "raw Claude" durch Proxy) —
das ist Absicht und Uebergang bis das neue Gehirn live ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:13:54 +02:00
duffyduckandClaude Opus 4.7 8be34e7284 fix(diagnostic): logBoxes.tts entfernt — Element gibt's nicht mehr, null.addEventListener crashte das ganze JS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:44:23 +02:00
duffyduckandClaude Opus 4.7 b56cef6298 rename(diagnostic): Pipeline-Tab → Trace (End-to-End-Mitschnitt einer Anfrage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:41:54 +02:00
duffyduckandClaude Opus 4.7 0d203af8fb chore(diagnostic): TTS-Diagnose-Tab raus (Voice-Settings sind eh in Einstellungen)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:37:35 +02:00
duffyduck 0468d0e603 release: bump version to 0.1.2.1 2026-05-11 19:28:32 +02:00
duffyduck 7cfc2ba058 release: bump version to 0.1.2.0 2026-05-11 19:27:33 +02:00
duffyduckandClaude Opus 4.7 da795d14f5 release: 0.1.2.0 — Text-Chat fertig, Ping-Pong stabil, OpenClaw als Basis (letzte OpenClaw-Version)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:25:46 +02:00
duffyduckandClaude Opus 4.7 d60c7e9110 docs(issue/readme): heutige Features dokumentiert — ARIA-Files, Reparatur-Buttons, Pinch-Zoom, Auto-Compact
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:20:10 +02:00
duffyduck 83c99a5e65 release: bump version to 0.1.1.9 2026-05-11 19:17:30 +02:00
duffyduckandClaude Opus 4.7 e438bb11ff feat(app): Pinch-Zoom + Pan im Vollbild-Modal
Neue ZoomableImage-Komponente — reine RN-Implementation mit
PanResponder + Animated, ohne extra Dependency.

- 2-Finger-Pinch: Zoom 1x..5x, Focal-Point folgt der Geste
- 1-Finger-Pan: nur aktiv wenn gezoomt, mit Bounds-Clamping
- Doppel-Tap: Toggle 1x ↔ 2.5x

Vollbild-Modal ersetzt das simple <Image> durch ZoomableImage fuer
JPEG/PNG/etc. SVGs bleiben non-zoomable (SvgUri-Limitation), Tap
schliesst sie. Plus dedicated ✕-Close-Button oben rechts da Tap-to-
Close mit PanResponder kollidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:15:58 +02:00
duffyduckandClaude Opus 4.7 8b4f75bf91 fix(diagnostic): /shared/uploads/-Bilder im History-Load auch inline rendern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:12:59 +02:00
duffyduckandClaude Opus 4.7 d7e7386954 fix(diagnostic): ARIA-Datei-Bubbles ueberleben Browser-Refresh
Beim Page-Reload laedt Diagnostic die Chat-History aus dem OpenClaw-
Session-File. file_from_aria-Events sind nur live-broadcast, nicht im
jsonl gespeichert → nach F5 waren die Anhang-Bubbles weg.

Fix: Server parsed [FILE: ...]-Marker aus assistant-messages beim
History-Load und schickt fuer existierende Files ein "aria_file"-
Message-Stueck mit allen Metadaten (Pfad, MIME, Groesse). Frontend
ruft addAriaFile mit denen, sodass die Bubbles wieder erscheinen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:08:06 +02:00
duffyduck 2100c64b91 release: bump version to 0.1.1.8 2026-05-11 19:04:25 +02:00
duffyduckandClaude Opus 4.7 74ebf59c6f feat(ui): ARIA-Abkuerzung ausgeschrieben in App-Ueber + Diagnostic-Einstellungen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:02:30 +02:00
duffyduck 53b49eacad release: bump version to 0.1.1.7 2026-05-11 18:59:57 +02:00
duffyduckandClaude Opus 4.7 0f11d23c75 fix(bridge): User informieren wenn ARIA Marker fuer nicht-existente Datei setzt
Bridge-Logs zeigten: ARIA setzt zwei Marker (aria_rave2.mid und .mp3),
hat die .mid aber nie wirklich erstellt. Bridge filterte sie silent →
Stefan sah nur eine Bubble und dachte das Marker-System ist kaputt.

Jetzt: _extract_file_markers gibt auch eine Liste der "missing"-Pfade
zurueck, und im Antworttext steht ein Hinweis-Block fuer den User
welche Files versprochen aber nicht erstellt wurden.

Plus System-Prompt geschaerft: ARIA soll vor dem Marker pruefen ob
das File wirklich existiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:41:22 +02:00
duffyduckandClaude Opus 4.7 311030bdaa fix(diagnostic): [FILE: ...]-Marker-Filter ueberall (in addChat statt nur chat_final)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:33:49 +02:00
duffyduck 1e05c66baa release: bump version to 0.1.1.6 2026-05-11 02:25:38 +02:00
duffyduckandClaude Opus 4.7 4082a6bf2a feat: Auto-Compact nach N User-Messages — verhindert E2BIG bei langer Session
E2BIG (Argument list too long) tritt auf wenn aria-core's Subprocess-
Spawn das Linux argv-Limit (~128KB-2MB) sprengt. Bei >140 Messages
samt Memory + System-Prompt + Tools laeuft das voll, ARIA antwortet
nur noch leer auf jede Anfrage.

Bridge zaehlt jetzt User-Nachrichten in send_to_core; bei COMPACT_AFTER_MESSAGES
(env, default 140) wird automatisch:
- Sessions geleert (rm sessions/*.jsonl + sessions.json = {})
- aria-core neu gestartet
- User informiert "Konversation komprimiert, letzte Nachricht nochmal"

Plus manueller "🧹 Konversation komprimieren"-Button in App-Settings
und 🧹 Compact-Button in Diagnostic-Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:30 +02:00
duffyduckandClaude Opus 4.7 3485642b3e fix(diagnostic): aria-restart ueber Docker-Socket-API statt CLI (Container hat kein docker installiert)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:03:08 +02:00
duffyduck 1240ae3829 release: bump version to 0.1.1.5 2026-05-11 01:59:58 +02:00
duffyduckandClaude Opus 4.7 2dd4d38dce feat: "ARIA hart neu starten"-Button (docker restart aria-core)
Zweiter Eskalations-Button neben dem Reparieren-Button — fuer Faelle
wo doctor --fix nicht reicht (Run alive aber haengt im Tool-Call).
Mit Confirmation-Dialog damit's nicht versehentlich gedrueckt wird.

Wege:
- App-Settings: Reparatur-Sektion, zwei Buttons (Reparieren + Hart neu)
- App-Thinking-Bubble: 🔧 + 🚨 + Abbrechen
- Diagnostic-Thinking-Indicator: 🔧 + 🚨 + Abbrechen
- Diagnostic-Server: POST /api/aria-restart → child_process exec
  `docker restart aria-core`
- Bridge: rvs aria_restart → HTTP zu Diagnostic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:58:44 +02:00
duffyduck 7f862ce1f4 release: bump version to 0.1.1.4 2026-05-11 01:48:33 +02:00
duffyduckandClaude Opus 4.7 528fe97b59 feat: "ARIA reparieren"-Button in App + Diagnostic
Bei stuck OpenClaw-Runs (ARIA antwortet nicht mehr / "Antwort ohne Text"
auf jede Anfrage) kann der User jetzt selbst openclaw doctor --fix
anstossen — ohne SSH/docker exec.

Pfad:
- App-Button → rvs.send('doctor_fix') → Bridge → HTTP POST an
  Diagnostic /api/doctor-fix → dockerExec aria-core
- Diagnostic-Button → direkt HTTP POST /api/doctor-fix

Zwei Plaetze in der App: oben in der Thinking-Bubble (wenn ARIA denkt
aber haengt) und in Settings → Reparatur (immer erreichbar). In
Diagnostic neben dem Abbrechen-Button im Thinking-Indicator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:35 +02:00
duffyduck 3483d1bfce release: bump version to 0.1.1.3 2026-05-10 18:47:10 +02:00
duffyduckandClaude Opus 4.7 158423c155 fix(app): SVG im Vollbild via SvgUri rendern (statt Image) — preserveAspectRatio damit nicht gestreckt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:46:13 +02:00
duffyduck 087e91dca1 release: bump version to 0.1.1.2 2026-05-10 18:44:05 +02:00
duffyduckandClaude Opus 4.7 2de4cbc00f fix(app): SVG-Anhaenge mit SvgUri rendern statt Image
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:42:53 +02:00
duffyduckandClaude Opus 4.7 03fc465057 fix(app): react-native-svg auf 14.1 (kompatibel mit RN 0.73) — 15.x braucht neuere RN-Version
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:35:15 +02:00
duffyduckandClaude Opus 4.7 b696b47feb feat(app): SVG-Inline-Rendering via react-native-svg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:33:30 +02:00
duffyduckandClaude Opus 4.7 6aae565541 docs(prompt): ARIA soll externe Bilder/Files runterladen statt nur verlinken
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:32:16 +02:00
duffyduckandClaude Opus 4.7 214bd218a0 feat(app): Inline-Bilder in Chat-Nachrichten anzeigen (wie in Diagnostic)
MessageText erkennt http(s)-URLs auf Bilder (jpg/png/gif/webp/bmp/ico)
und rendert sie als <Image> unter dem Text. Markdown-Syntax
![alt](url) wird durch dasselbe Regex erfasst weil die URL drin ist.
SVGs ausgespart — React Native Image kann SVG nicht ohne Extra-Lib.

Aspect-Ratio wird via Image.getSize ermittelt, gecapped auf 0.5..2.5
damit Panorama-/Streifen-Bilder die Bubble nicht sprengen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:30:55 +02:00
duffyduckandClaude Opus 4.7 2afeee29ee feat(diagnostic): ARIA-Datei-Pfad als kleiner Debug-Footer in der Bubble
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:27:17 +02:00
duffyduckandClaude Opus 4.7 c8dee4c416 fix(diagnostic): [FILE: ...]-Marker aus chat_final rausfiltern
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:26:35 +02:00
duffyduckandClaude Opus 4.7 f49f3c3b08 fix(prompt): File-Marker-Anweisung in BOOTSTRAP.md (echter System-Prompt) statt AGENT.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:23:22 +02:00
duffyduckandClaude Opus 4.7 c4bbb06710 docs(agent): File-Marker-Anweisung deutlich schaerfer formuliert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:18:35 +02:00
duffyduckandClaude Opus 4.7 4411cc4fff chore: init.sh — Setup-Script materialisiert *.example zu Config-Dateien
Frisch geclonte Repo / git pull nach .gitignore-Aenderungen lassen
USER.md (und andere Config-Files) fehlen — docker compose up failt
dann beim Bind-Mount. init.sh kopiert idempotent alle *.example zu
ihren Originalen wenn die noch nicht existieren.

Nach git clone und git pull empfohlen: bash init.sh

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:21 +02:00
duffyduckandClaude Opus 4.7 24a91887ef fix(android): FileOpenerModule — kein '*/*' im Source (Kotlin-Lexer-Verwirrung)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:03:59 +02:00
duffyduckandClaude Opus 4.7 4e62b2919f chore(config): USER.md aus Repo nehmen — enthielt interne Tool-Liste
USER.md hatte Stefan-spezifische Infos (Gitea-URL hackersoft.de,
OpenCRM, STARFACE, RustDesk Tool-Stack). Hat im Repo nichts zu suchen.

USER.md ist jetzt in .gitignore, USER.md.example als Vorlage
eingecheckt. Lokale USER.md bleibt funktional unangetastet — nur
nicht mehr getrackt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:00:45 +02:00
duffyduckandClaude Opus 4.7 fa774156fe docs(agent): ARIA-System-Prompt um File-Marker-Anweisung ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:58:36 +02:00
duffyduckandClaude Opus 4.7 3b19f05c5b feat: ARIA kann Dateien an User zurueckgeben (PDFs, Bilder, Office-Docs, ...)
ARIA setzt im Antworttext einen Marker `[FILE: /shared/uploads/aria_xxx.ext]`.
Bridge extrahiert ihn (Marker wird aus dem TTS-Text entfernt) und sendet
ein neues file_from_aria-Event ueber RVS an App + Diagnostic.

Diagnostic:
- Eigene Bubble mit Datei-Icon + Klick-Handler
- PDF/Bild → neuer Browser-Tab via /shared/* HTTP-Route
- Andere → Download via download-Attribut

App:
- Neues FileOpenerModule (Kotlin) — Intent.ACTION_VIEW mit FileProvider,
  Android-Picker waehlt App nach MIME-Type
- file_paths.xml erweitert (cache + files + external)
- file_response liefert jetzt mimeType mit
- Klick auf ARIA-Anhang: lokal vorhanden → direkt oeffnen, sonst
  file_request mit autoOpen-Flag → bei Empfang persistAttachment + open

Stefan muss noch im aria-core/OpenClaw System-Prompt einen Hinweis
einbauen: "Wenn du dem User eine Datei erstellt hast (Pfad in
/shared/uploads/), haenge am Ende deiner Antwort einmalig
[FILE: /shared/uploads/aria_<name>.<ext>] an. Der Marker wird aus dem
sichtbaren Text entfernt und als Anhang in App und Diagnostic angezeigt."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:56:47 +02:00
duffyduckandClaude Opus 4.7 fc3ecaacca docs(issue): heutige Session-Fixes ergaenzt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:32:05 +02:00
duffyduck 08857093b5 release: bump version to 0.1.0.9 2026-05-10 17:25:48 +02:00
duffyduckandClaude Opus 4.7 62018b3e51 revert(audio): kickReleaseMedia raus — bricht Spotify's Auto-Resume
Logs zeigen jetzt KEINEN haengenden RNSound-Focus mehr (Library-Version
oder Sound-Lifecycle hat sich geaendert). Der Kick mit AUDIOFOCUS_GAIN
(permanent) sagte Spotify "user hat manuell etwas anderes gestartet" →
Spotify resumed nicht automatisch.

Ohne Kick: unser Focus war AUDIOFOCUS_GAIN_TRANSIENT (USAGE_ASSISTANT) —
beim release bekommt Spotify einen sauberen GAIN nach TRANSIENT-Loss
und resumed automatisch.

Native kickReleaseMedia bleibt fuer den Fall dass es nochmal gebraucht
wird, wird aber nicht mehr gerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:24:55 +02:00
duffyduck 89e3a195a3 release: bump version to 0.1.0.8 2026-05-10 17:21:46 +02:00
duffyduckandClaude Opus 4.7 f023ba0ac5 fix(audio): Mute-Button = Stop fuer aktuelle Antwort, nie Resume
Bisheriges Verhalten: Mute drueckt → stopPlayback. Mute zurueck → noch
eingehende chunks der gleichen Antwort starteten einen neuen Stream und
ARIA redete weiter wo sie war. Funktionierte nur 2x weil dann isFinal
schon kam und keine chunks mehr fluten.

Stefan: "Mund verbieten = Stop, fertig". Neue Antworten sollen normal
spielen.

Fix: _stoppedMessageId-Tracking. Bei Mute=true wird die aktuelle msgId
gemerkt — alle weiteren chunks dieser msgId bleiben silent, auch wenn
Mute zurueckgenommen wird. Reset bei neuer msgId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:20:29 +02:00
duffyduck a0570ef8f7 release: bump version to 0.1.0.7 2026-05-10 17:14:11 +02:00
duffyduckandClaude Opus 4.7 facde1fef7 fix(audio): kickReleaseMedia auch im PCM-Pfad — re-renderte Antworten muteten Spotify dauerhaft
Stefan: ältere Nachrichten (deren Cache-WAV weg ist) gehen ueber
tts_request neu rendern → kommen als PCM-Stream zurueck → werden ueber
PcmStreamPlayer abgespielt. Beim Mute lief stopPlayback aber ohne den
Spotify-resume-Kick weil hadRnSound=false war (kein currentSound).

Jetzt: kickReleaseMedia immer in stopPlayback rufen — kostet nichts,
deckt PCM- und RNSound-Pfad ab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:13:12 +02:00
duffyduck 38106a2096 release: bump version to 0.1.0.6 2026-05-10 17:07:53 +02:00
duffyduckandClaude Opus 4.7 a476afb311 fix(audio): kickReleaseMedia mit 250ms Pause zwischen request+abandon — Spotify kriegt den Focus-Wechsel sonst gar nicht mit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:07:03 +02:00
duffyduck db4c7b9b72 release: bump version to 0.1.0.5 2026-05-10 17:02:56 +02:00
duffyduckandClaude Opus 4.7 3bc490b485 fix(audio): stopPlayback idempotent — kein doppelter Focus-Kick
Re-Renders / setInterval(loadSettings) triggern setMuted(true) oft
mehrfach hintereinander → jeder weitere stopPlayback rief erneut
kickReleaseMedia, Spotify pausierte+resumte mehrfach (Stefan: "spielt
kurz und pausiert dann wieder").

Fix: stopPlayback returnt sofort wenn nichts mehr aktiv ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:02:01 +02:00
duffyduck dd6d70c46e release: bump version to 0.1.0.4 2026-05-10 16:59:15 +02:00
duffyduckandClaude Opus 4.7 b1eaf42fef fix(audio): Spotify resumed nach Mute — RNSound's haengenden Focus loesen
Logs zeigten: react-native-sound requestet beim Sound.play() einen
EIGENEN AudioFocus mit USAGE_MEDIA, released den aber bei Sound.stop()/
release() NICHT (bekanntes RN-sound-Bug). Spotify sieht den haengenden
Media-Focus → bleibt pausiert.

Workaround: Native-Methode kickReleaseMedia() macht einen request+abandon-
Cycle mit USAGE_MEDIA, das System raeumt damit den Focus-Stack auf und
Spotify bekommt sauberen GAIN-Event. stopPlayback ruft das jetzt nach
Sound.release() wenn vorher ein RNSound aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:57:52 +02:00
duffyduckandClaude Opus 4.7 fb9e5dcd10 feat(logger): Verbose-Logging-Toggle in Settings → Protokoll
console.log wird global stummgeschaltet wenn aus — spart adb-logcat-
Speicher wenn alles laeuft. console.warn/error bleiben immer aktiv.
Default an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:52:25 +02:00
duffyduck f95e71463f release: bump version to 0.1.0.3 2026-05-10 16:43:37 +02:00
duffyduckandClaude Opus 4.7 1088bff43d fix(chat): Play-Button rendert neu wenn Cache-Datei weg
Vorher: Button checkte nur ob audioPath gesetzt ist — auf eine geloeschte
Cache-Datei hat aber nichts geprueft. playFromPath warntete nur und
returnte stumm. Jetzt wird VOR playFromPath die Existenz geprueft, sonst
geht's ueber tts_request an die Bridge zum Neu-Rendern.

Plus: Logs in Sound.play-Callback und _releaseFocusDeferred fuer den
"Spotify resumed nicht nach Replay"-Bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:42:38 +02:00
duffyduck cad68db2a2 release: bump version to 0.1.0.2 2026-05-10 16:38:00 +02:00
duffyduckandClaude Opus 4.7 50b10c8ac0 feat(audio): Cache-Cleanup beim App-Start + TTS-Cache-Settings-Button
- App-Start raeumt orphane aria_tts_*.wav (>5min) aus dem Cache —
  Wiedergaben die durch Anruf/Mute/Barge-In abgebrochen wurden
  hinterliessen sonst Files, weil der completion-Callback nicht feuert.
- Neuer Settings-Button "TTS-Cache leeren" mit Live-Groessenanzeige —
  parallel zum bestehenden "Update-Cache leeren".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:36:01 +02:00
duffyduck a8b586ec92 release: bump version to 0.1.0.1 2026-05-10 16:30:14 +02:00
duffyduckandClaude Opus 4.7 632e1e4fa1 fix(audio): pauseForCall setzt isPlaying zurueck — Playback nach Anruf nicht mehr tot
pauseForCall stoppte zwar currentSound + setzte ihn auf null, hat aber
isPlaying=true gelassen. Folge: nach dem Anruf war jeder weitere Play-
Button-Klick wirkungslos, weil playAudio bei isPlaying=true den
_playNext-Pfad ueberspringt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:42 +02:00
duffyduck 7e12816ebd release: bump version to 0.1.0.0 2026-05-10 16:22:08 +02:00
duffyduckandClaude Opus 4.7 8f64f8fb30 fix(phone): 800ms-Delay vor Auto-Resume — Spotify kommt zum Atmen
Wenn ARIA's Resume-Pfad direkt nach Anruf-Ende den AudioFocus requestet,
kollidiert das mit Spotify's eigenem Auto-Resume. System haengt noch im
IN_CALL-Mode-Uebergang, Spotify sieht "Loss → Loss" und bleibt pausiert
statt kurz zu resumen.

Mit 800ms-Delay: Spotify schafft den Resume-Schritt, dann pausiert ARIA
wieder ordnungsgemaess. Wenn ARIA nichts pending hatte, bleibt Spotify
einfach weiter an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:20:48 +02:00
duffyduckandClaude Opus 4.7 b3ff3991c4 feat(bridge): Bilder >2MB serverseitig auf 1568px verkleinern
Claude-Vision-API hat ~5MB Base64-Limit. Stefan's 4MB Foto via
Buroklammer (DocumentPicker) sprengte das, Claude lieferte leere
Antwort zurueck. Galerie-Pfad ging weil react-native-image-picker
schon clientseitig komprimiert.

Bridge resized jetzt JPEG/PNG/WebP/GIF >2MB auf max 1568px lange
Seite (Anthropic-Empfehlung), JPEG q=85. SVG, PDF, ZIP, Office-Docs
bleiben unangetastet — die laufen ueber Tools, nicht Vision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:15:05 +02:00
duffyduckandClaude Opus 4.7 a4ea387c98 fix(bridge): "chat final ohne Text" wird sichtbar an App gemeldet
Wenn Claude-Vision das Bild silent ablehnt (z.B. zu gross), kommt
phase=end ohne Crash, aber chat:final ohne text. Bridge ignorierte das
nur mit Warning — App wartete ewig auf Antwort. Jetzt kommt eine
Hinweis-Bubble damit der User weiss dass was schief lief.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:14 +02:00
duffyduckandClaude Opus 4.7 68fbf74a23 fix(bridge): chat:error liest auch errorMessage — kein "Unbekannt" mehr
OpenClaw legt bei state=error den Text in errorMessage statt error.
Bridge ignorierte das und meldete generisches "[Fehler] Unbekannt" an
App + Diagnostic — der echte Text ("Process exited with code 1" etc)
ging nur in die Container-Logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:20 +02:00
duffyduck b857f778e9 release: bump version to 0.0.9.9 2026-05-10 15:56:53 +02:00
duffyduckandClaude Opus 4.7 31aa82b68c debug+fix(audio): Mute-Logs + resumeSound auch in stopPlayback stoppen
stopPlayback stoppte bisher nur currentSound, nicht resumeSound — wenn
nach einem Anruf der Auto-Resume laeuft und der User Mute drueckt, bleibt
der Resume-Sound weiter spielen.

Plus Logs in setMuted/stopPlayback um zu sehen warum Stefans Mute beim
Replay nicht greift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:55:51 +02:00
duffyduck de8eeb69e2 release: bump version to 0.0.9.8 2026-05-10 15:46:36 +02:00
duffyduckandClaude Opus 4.7 f5970ce700 fix(audio): _firePlaybackStarted ueberschrieb playFromPath-Tracking mit leerem pcmMessageId
Logs zeigten: playFromPath setzt currentPlaybackMsgId='db710ff3-...', 9s
spaeter beim Anruf war captureInterruption msgId=(leer). Ursache:
_firePlaybackStarted setzt currentPlaybackMsgId blind aus pcmMessageId —
das ist beim Play-Button leer.

Jetzt nur noch setzen wenn ein PCM-Stream laeuft. Play-Button und Resume-
Sound setzen ihr Tracking selber im Caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:28 +02:00
duffyduckandClaude Opus 4.7 ef1a4436ca fix(bridge): WebSocket max_size auf 50MB — grosse Bilder/Uploads gehen wieder
Python websockets Default-Limit ist nur 1 MiB. Stefan's 4MB JPEG (5.8MB als
Base64) sprengte das, Bridge-Verbindung wurde silent gedroppt. App sah
nichts, Diagnostic kriegte kein file_saved, ARIA reagierte nicht — Kamera-
Bilder waren klein genug (<1MB) und gingen darum durch.

f5tts/whisper-bridges hatten max_size=50MB schon drin, nur aria_bridge
hatte's an beiden websockets.connect-Stellen vergessen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:42:48 +02:00
duffyduck 981779cd9e release: bump version to 0.0.9.7 2026-05-10 15:37:45 +02:00
duffyduckandClaude Opus 4.7 3dcd2ae0b4 fix(audio): msgId-Regex liberaler — auch nicht-UUID-Dateinamen werden erkannt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:43 +02:00
duffyduck 2750b867a3 release: bump version to 0.0.9.6 2026-05-10 15:29:03 +02:00
duffyduckandClaude Opus 4.7 f6424add6c debug(chat): Logs fuer Anhang-Send-Pipeline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:27:24 +02:00
duffyduckandClaude Opus 4.7 2dfd21d1d0 fix(audio): Play-Button setzt jetzt auch Wiedergabe-Tracking — Anruf-Test via Playback funktioniert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:22:21 +02:00
duffyduckandClaude Opus 4.7 9d9ddc730b debug+fix(audio): mehr Anruf-Logs + Tracking auch beim Resume-Sound
Im Test 2 (zweiter Anruf in derselben Antwort) kam weder captureInterruption
noch resumeFromInterruption als Log — beide returnen frueh ohne Hinweis warum.
Jetzt loggen sie auch den Skip-Pfad damit man sieht ob's der idempotent-Guard
oder fehlende playbackStartTime ist.

Plus: _playFromPathAtPosition aktualisiert jetzt currentPlaybackMsgId und
playbackStartTime — sonst stehen die auf den Werten der ersten TTS-Wiedergabe
und ein zweiter Anruf-captureInterruption wuerde mit veraltetem Stand laufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:20:43 +02:00
duffyduck 77ccee8331 release: bump version to 0.0.9.5 2026-05-10 15:13:38 +02:00
duffyduckandClaude Opus 4.7 175dcdf225 fix(audio): Auto-Resume nach Anruf — pcmBuffer bleibt erhalten
Logs zeigten "WAV nicht binnen 30000ms verfuegbar" — pcmBuffer wurde von
haltAllPlayback geleert, isFinal schrieb daher eine leere WAV (oder gar
keine, weil pcmMessageId leer war).

Neue Methode pauseForCall (statt haltAllPlayback im Anruf-Pfad):
- AudioTrack stoppt + AudioFocus release (Spotify resumed)
- pcmBuffer + pcmMessageId BLEIBEN — Bridge-Chunks werden weiter gesammelt
- _pausedForCall macht weitere Chunks "silent" (kein writeChunk, nur Cache)
- isFinal schreibt WAV trotz Anruf → resumeFromInterruption findet sie

Plus captureInterruption idempotent gemacht: ringing→offhook ueberschreibt
die Position vom ersten Halt nicht mehr (Date.now-Tracking laeuft stumpf
weiter obwohl Audio gestoppt ist — der erste Halt ist die echte Position).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:11:46 +02:00
duffyduckandClaude Opus 4.7 1549e9cd4f docs(issue): vier neue Fixes der Debug-Session festgehalten
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:01:37 +02:00
duffyduckandClaude Opus 4.7 910e74b497 fix(bridge): GPS-Position auch im STT-chat-Payload an Diagnostic mitgeben
Die App sendet location einmal im audio-Payload. Die Bridge kannte sie
zwar (ging in aria-core's Kontext rein), reichte sie aber nicht im STT-
broadcast an die Diagnostic durch. Diagnostic zeigte darum bei Sprach-
eingaben nie den GPS-Block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:59:09 +02:00
duffyduck 160c5c34b6 release: bump version to 0.0.9.4 2026-05-10 14:54:45 +02:00
duffyduckandClaude Opus 4.7 a6638c0108 debug(gps): Logs fuer Standort-Abfrage und Permission-Fehler
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:53:32 +02:00
duffyduck 43c21d3ddc release: bump version to 0.0.9.3 2026-05-10 14:48:35 +02:00
duffyduckandClaude Opus 4.7 b73c6c346e fix(gps): Standort-Permission anfordern — sonst sendet App nie eine Position
Im Manifest fehlte ACCESS_COARSE/FINE_LOCATION komplett, und der
Settings-Toggle requestete keine Runtime-Permission. Geolocation
.getCurrentPosition() schlug darum lautlos fehl, App sendete nie ein
location-Feld → Diagnostic konnte nichts anzeigen, auch wenn der
Diagnostic-eigene "GPS einblenden"-Toggle aktiv war.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:47:35 +02:00
duffyduckandClaude Opus 4.7 b91ddc5bdf fix(audio): AudioTrack-Start-Threshold auf 100ms — kurze TTS startet jetzt
ENDLICH die Wurzel: AudioTrack hat seit API 31 setStartThresholdInFrames(),
default ist bufferSize/2. Bei 4s-Buffer = 2s Threshold — Track wartet bis
2s im Buffer sind, sonst startet play() nie wirklich (pos bleibt 0).

Bei 3 Worten (~1.4s) kommt's nie ueber die Schwelle. Threshold runter
auf 100ms (2400 Frames @ 24kHz) — Track laeuft sofort mit erstem Chunk an.

Erklaert auch warum genau ab 9 Worten (~3s+) der Pre-Roll-Pfad lief: dann
wurde die 2s-Schwelle ueberschritten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:45:05 +02:00
duffyduck 7d08c06720 release: bump version to 0.0.9.2 2026-05-10 14:40:35 +02:00
duffyduckandClaude Opus 4.7 f066a2a555 fix(audio): Mute-Button stoppt jetzt auch laufenden PCM-Stream
pcmStreamActive wurde beim isFinal-Chunk schon auf false gesetzt, der
AudioTrack spielte aber noch aus seinem Buffer (kann sekundenlang sein).
stopPlayback() uebersprang darum PcmStreamPlayer.stop() — ARIA redete
weiter obwohl Spotify schon resumed war.

Fix: stop() immer rufen, der Flag-Check faellt weg (ist eh idempotent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:39:27 +02:00
duffyduckandClaude Opus 4.7 b55b0e7c42 fix(audio): play() beim 1. Chunk — kurze Texte stallen nicht mehr
Logs zeigten: Pre-Roll-Pfad (play() WAEHREND chunks reinkommen) lief
immer sauber, Kurz-Text-Pfad (play() NACHDEM Buffer komplett gefuellt
ist) stallte immer — egal mit wie viel Daten oder welchem USAGE-Tag.

Fix: play() beim allerersten data-chunk callen, kein Pre-Roll-Threshold
mehr. AudioTrack ist sofort im PLAYING-State, weitere chunks/trailing
fliessen parallel ab. Padding-Block nach mainLoop entfaellt komplett.

USAGE_MEDIA wieder auf USAGE_ASSISTANT zurueck — war nicht die Ursache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:38:02 +02:00
duffyduck 70f806ef80 release: bump version to 0.0.9.1 2026-05-10 14:32:35 +02:00
duffyduckandClaude Opus 4.7 0773d9496d fix(audio): AudioTrack auf USAGE_MEDIA — USAGE_ASSISTANT stallt auf OnePlus A12
Letzter Test zeigte: 163456B im Buffer mit play()-nach-Padding stallt
(pos=0), aber 170048B im Pre-Roll-Pfad startet einwandfrei. Differenz
nur 4% Daten — kein Buffer-Threshold-Problem, sondern AudioTrack-Quirk
mit USAGE_ASSISTANT bei "voller Buffer, dann play()".

USAGE_MEDIA ist robuster, AudioFocus laeuft eh separat ueber das
AudioFocusModule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:31:23 +02:00
duffyduck 1a4857ed62 release: bump version to 0.0.9.0 2026-05-10 14:26:41 +02:00
duffyduckandClaude Opus 4.7 962d814318 fix(audio): kurze TTS — Padding auf 3s erhoeht (OnePlus A12 Hard-Threshold)
Test mit 96000B (2s) Padding zeigte: AudioTrack stallt immer noch mit
pos=0/48000. Ab 8 Worten (~2.5s) geht's — der Hard-Threshold liegt also
zwischen 2s und 3s. Padding auf 3s, Buffer auf 4s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:25:43 +02:00
duffyduck 9276a92c83 release: bump version to 0.0.8.9 2026-05-10 14:20:43 +02:00
duffyduckandClaude Opus 4.7 d16896c4b4 fix(audio): kurze TTS-Texte — play() erst NACH Buffer-Fuellung mit Padding
Auf OnePlus A12 startet AudioTrack nicht zuverlaessig wenn play() bei
duennem Buffer gerufen wird (pos blieb 0/34112 trotz 71KB Daten + Retry).

Neue Reihenfolge bei kurzem Stream:
1. Daten in Buffer schreiben (mainLoop)
2. Trailing-Silence (0.3s)
3. Padding bis min. 2s gepuffert
4. DANN erst play()

Buffer auf 3s erhoeht damit blockingem write() noch Headroom bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:19:45 +02:00
duffyduck 20050d4077 release: bump version to 0.0.8.8 2026-05-10 14:12:59 +02:00
duffyduckandClaude Opus 4.7 79760d1b2e fix(audio): kurze TTS-Texte spielen wieder ab — AudioTrack-Buffer entkoppelt von Preroll
OnePlus A12 stallte bei kurzem Text mit pos=0/34112: 336KB Buffer fuer
3.5s Preroll, aber nur 68KB Daten drin → AudioTrack faehrt nicht an.

Fix: Buffer fest auf ~2s, plus play()-Retry bei pos=0 nach 500ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:11:53 +02:00
duffyduck 13f1103604 release: bump version to 0.0.8.7 2026-05-10 14:00:29 +02:00
duffyduckandClaude Opus 4.7 73b7a76ea8 fix(phone-call): kein VoIP-Toast bei Play-Button — AudioMode pruefen
Stefan: 'Möchte ich mir playbacks anhören egal welches kommt die toast
nachricht voip anruf und danach aria wieder aktiv'.

Ursache: AUDIOFOCUS_LOSS feuert bei jedem Audio-Player-Wechsel
(Spotify, andere Apps, sogar unsere eigenen Sound-Calls). Wir
interpretierten das blind als VoIP-Anruf.

Fix: vor dem Halt fragen wir AudioFocus.getMode() ab — nur wenn
mode == 2 (IN_CALL) oder 3 (IN_COMMUNICATION) ist's wirklich ein
Anruf. Bei NORMAL (0) wird der Loss ignoriert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:40 +02:00
duffyduck 17f3d8870e release: bump version to 0.0.8.6 2026-05-10 12:50:35 +02:00
duffyduckandClaude Opus 4.7 4feaacc7e4 feat(update): APK-Cache robuster + manueller 'Update-Cache leeren' Button
Stefan: 'app blaeht sich auf durch heruntergeladene Update-Versionen'.

updater.ts:
- cleanupOldApks durchsucht jetzt 4 Pfade (Caches, Documents, ExternalCaches,
  ExternalDir) statt nur CachesDirectoryPath
- Public gemacht + returnt {removed, freedMB}
- getApkCacheSize() neu — listet count + totalMB

SettingsScreen → Speicher:
- Neue Sektion 'Update-Cache' mit Live-Groessenanzeige
- Button 'Update-Cache leeren' triggert cleanup + Toast mit Ergebnis
- Beim Mount wird die Groesse einmal geladen

Auto-Cleanup laeuft weiterhin beim App-Start + vor jedem Download —
der Button ist fuer den Notfall (haengender Download, alte Pfade,
defekte APKs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:49:22 +02:00
duffyduckandClaude Opus 4.7 af7b2674f3 docs: Auto-Resume + Edge-Cases in issue.md + README
issue.md: Audio-Tabelle erweitert um 'neue Frage waehrend Anruf' und
'Anruf vorbei nach neuer Frage'. Mechanismen-Liste ergaenzt mit
'Audio-Ausgabe waehrend Telefonat' (state-change Logik) und 'neue
Frage verwirft pending Resume'. Drei neue Erledigt-Eintraege fuer
VoIP, Auto-Resume und PcmPlaybackFinished-Event.

README: kompakte Audio-Tabelle ergaenzt + Roadmap zwei neue Bullets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:41:34 +02:00
duffyduckandClaude Opus 4.7 97442198ec fix(audio): neue ARIA-Antwort verwirft pending Auto-Resume
Stefans Edge-Case: waehrend des Telefonats stellt der User eine neue
Text-Frage. Die neue ARIA-Antwort startet sofort (offhook→offhook
loest keinen halt aus). Vorher haette resumeFromInterruption nach
Anruf-Ende noch die ALTE Antwort (die unterbrochen wurde) ab
Position spielen wollen — Konflikt mit der neuen Antwort.

Fix: in _handlePcmChunkImpl beim Wechsel zu einer neuen messageId:
- laufenden resumeSound stoppen
- pausedMessageId = '' wenn != neue messageId

Damit gewinnt immer die neueste Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:39:33 +02:00
duffyduckandClaude Opus 4.7 e3e841f2ab feat(audio): Auto-Resume nach Anruf ab der gemerkten Position
Stefans Idee: Position beim Halt merken (Date.now() - playbackStart -
leadingSilence), nach dem Auflegen ab da weitermachen. Wenn der Cache
noch nicht komplett ist (final-Marker kam waehrend Anruf), warten wir
bis zu 30s auf das WAV — meistens ist's schon da weil das Telefonat
laenger als die Antwort dauerte.

audio.ts:
- captureInterruption(): merkt position + messageId, returnt Sekunden
- resumeFromInterruption(maxWaitMs): wartet auf WAV-Cache, lädt mit
  Sound, setCurrentTime(position), play
- Tracking-Felder: playbackStartTime, currentPlaybackMsgId, pausedX

phoneCall.ts:
- _haltForCall ruft captureInterruption() VOR haltAllPlayback
- _resumeAfterCall triggert resumeFromInterruption(30s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:37:35 +02:00
duffyduckandClaude Opus 4.7 33185de42b fix(audio): AudioFocus erst beim NATIVEN Playback-Finished-Event released
Logcat-Befund:
12:22:54.860 — final-Chunk + Cache geschrieben
12:22:55.402 — abandonAudioFocus (~0.5s spaeter)
12:22:55     — Spotify resumed (Atlas: TotalTime 93s)
12:23:27.064 — Playback fertig (32s spaeter!)

→ ARIA spricht 32s parallel zu Spotify weil end() viel zu frueh
returnt. Stefans 'Spotify resumed obwohl ARIA noch redet'.

Fix:
- PcmStreamPlayerModule emittiert 'PcmPlaybackFinished' RN-Event nach
  dem finally{}-Block im Writer-Thread (= AudioTrack hat alle Samples
  wirklich durchgespielt)
- audioService subscribed im constructor → ruft erst dann
  _releaseFocusDeferred()
- _handlePcmChunkImpl bei isFinal triggert NICHT mehr direkt das
  Release — nur die playbackFinished-Listener (UI-Logic)

So bleibt Spotify pausiert bis ARIA tatsaechlich fertig ist, egal
wie viel Audio im AudioTrack-Buffer wartet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:29:55 +02:00
duffyduckandClaude Opus 4.7 dbe547d4ea feat(diagnostic): GPS-Position als Debug-Block in Chat einblendbar
Toggle 'GPS-Position einblenden' rechts neben 'TTS-Text einblenden'.
Wenn aktiv und ein chat-Event hat ein location-Feld, erscheint unter
der Bubble ein gruener Block mit lat/lon — Klick oeffnet OpenStreetMap
am Punkt.

Nur Diagnostic, keine Anzeige in der App. Der Block taucht nur bei
User-/STT-/Diagnostic-Nachrichten auf (sender != aria), weil aria-core
sich nicht selbst lokalisiert.

Toggle-State wird in localStorage persistiert (aria-show-gps-debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:10:34 +02:00
duffyduck 1a982c0d45 release: bump version to 0.0.8.5 2026-05-10 12:01:46 +02:00
duffyduckandClaude Opus 4.7 dfba5ceb1f docs: Audio-Verhaltens-Tabelle in issue.md + README
Definiert klar wann Spotify pausiert und wann nicht — als Referenz
fuer kuenftige Bug-Reports. Aktueller Zustand nach den Audio-Fixes:
Spotify pausiert nur waehrend User-Aufnahme + TTS-Wiedergabe, nicht
waehrend ARIAs Denkphase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:00:41 +02:00
duffyduckandClaude Opus 4.7 1a6f633836 fix(audio): rollback agentActivity-Conversation-Focus, Spotify pausiert NUR bei TTS
Der vorige Commit (acquireConversationFocus bei agentActivity != idle) war zu
aggressiv — Spotify pausierte schon waehrend 'ARIA denkt/schreibt' und das
zugehoerige release greift nicht zuverlaessig (Race mit nachfolgenden
agent_activity-Events). Stefan: 'spotify resumet nicht mehr, hoert schon
beim ARIA-denkt-Passus auf zu spielen'.

Erwartetes Verhalten:
- Aufnahme: AudioFocus → Spotify pausiert (~5s)
- ARIA denkt/schreibt (~20s): kein Focus → Spotify spielt weiter
- TTS: AudioFocus per requestDuck → Spotify pausiert
- TTS-Ende: deferred release nach 800ms → Spotify resumed

Underrun-Schutz im PcmStreamPlayer haelt Spotify durchgehend gepaust
solange TTS rendert (auch in den GPU-Pausen zwischen Saetzen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:13 +02:00
duffyduck 7f7db100af release: bump version to 0.0.8.4 2026-05-10 11:53:48 +02:00
duffyduckandClaude Opus 4.7 d646e9d58e fix(audio): Spotify spielt nicht mehr in der ARIA-Verarbeitungspause
Logcat-Befund: zwischen User-Aufnahme-Ende und TTS-Start liegt eine
~20s-Pause (Whisper STT + Claude + F5-TTS). In dieser Zeit hatte ARIA
keinen AudioFocus → Spotify lief munter weiter, dann pausierte beim
TTS-Start. Stefan hoerte das als 'Spotify kommt nach 20s wieder'.

Fix: ChatScreen ruft acquireConversationFocus sobald ein agent_activity-
Event mit activity != 'idle' kommt. Solange ARIA arbeitet (thinking/
tool/responding) bleibt der Focus gehalten, Spotify bleibt pausiert.
Bei onPlaybackFinished oder cancelRequest wird releaseConversationFocus
gerufen — sonst bliebe Spotify ewig stumm.

Funktioniert auch fuer reine Text-Chats (kein Wake-Word noetig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:52:07 +02:00
duffyduck bef59ba134 release: bump version to 0.0.8.3 2026-05-10 11:46:26 +02:00
duffyduckandClaude Opus 4.7 dbebfd44ff fix(tts): Idle-Cutoff im PCM-Writer von 30s auf 120s
Bug-Vermutung: lange F5-TTS-Antworten reissen ab wenn die Gamebox
zwischen Saetzen >30s braucht (Modell-Wechsel, kalte GPU, ungewoehnlich
schwerer Satz). Writer-Thread brach dann mit 'Idle-Cutoff' ab und
ARIA verstummte mitten im Text.

120s deckt auch lange GPU-Pausen ab. Bei echtem Bridge-Crash brauchen
wir trotzdem irgendwann einen Cutoff damit der Foreground-Service
nicht ewig haengt.

Stefan kann ADB-Logs gerade nicht ziehen (telefoniert) — bei Bug 3
(Spotify) muessen wir noch die Native-Logs sehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:37:59 +02:00
duffyduckandClaude Opus 4.7 4d0b9e0d78 fix: dB-Range -85, Mute haert auch laufende TTS, VoIP-Anrufe + Bild-Bubble
Bug 1 — dB-Range erweitert:
VAD_SILENCE_DB_MIN von -55 auf -85 dB. Damit hat Stefan einen weiten
Regler-Spielraum wenn die adaptive Auto-Erkennung in seiner Umgebung
nicht zuverlaessig greift.

Bug 5 — Mute-Button stoppt laufende TTS nicht:
audioService bekommt jetzt einen internen _muted-Flag. handlePcmChunk
setzt silent automatisch wenn _muted true ist, playAudio kehrt frueh
zurueck. Verhindert Race zwischen User-Klick auf Mute und einem
TTS-Chunk der im selben JS-Tick ankommt (vorher: Ref-Update via
useEffect erst nach dem Re-Render → Chunks "rutschten durch"). Plus
ttsCanPlayRef wird im toggleMute-Handler synchron aktualisiert.

Bug 4 — VoIP/Messenger-Anrufe erkennen:
AudioFocusModule emittiert jetzt "AudioFocusChanged" Events mit type
"loss"/"loss_transient"/"gain". WhatsApp/Signal/Discord/etc. requestn
AudioFocus_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
fangen das in phoneCall.ts ab und rufen halt + pauseForCall genau
wie beim klassischen Anruf. Plus getMode() Polling-Fallback (alle 3s)
weil GAIN nicht zuverlaessig kommt wenn wir den Focus selbst released
haben — sobald AudioMode wieder NORMAL ist, resumeFromCall.

Bug 6 — Bilder als "Strich":
attachmentImage hatte width: '100%' in einer Bubble mit maxWidth: '80%'
ohne explizite Parent-Breite → RN rendert auf 0px Breite. Neue ChatImage-
Komponente nutzt Image.getSize um die echte aspectRatio zu messen + setzt
sie dynamisch. Bubble passt sich dem Bild an.

Bugs 2 (lange Texte mid-cutoff) + 3 (Spotify resumed) — brauchen ADB-Logs.
ADB-WLAN ueber 192.168.177.22:5555 schlaegt fehl (refused) — bei Android
11+ braucht's Wireless-Debugging-Pairing-Code. Stefan kann den nennen
sobald er soweit ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:28:52 +02:00
duffyduck 0c43a18402 release: bump version to 0.0.8.2 2026-05-07 08:31:06 +02:00
duffyduckandClaude Opus 4.7 5bdcc3c65b feat(vad): Stille-Pegel manuell in Settings + Info-Modal
Wenn die adaptive Baseline-Logik in einer Umgebung nicht zuverlaessig
greift (Stefan: "manchmal funktioniert die Stille-Erkennung nicht"),
kann der User die Schwelle jetzt manuell setzen.

Settings → Spracheingabe:
- "Stille-Pegel (dB)" mit −1/+1 Buttons + "Auf automatisch zuruecksetzen"
- Range −55 bis −15 dB, default "auto" (= adaptive Baseline)
- Info-Icon (i) oeffnet Modal mit Erklaerung:
  • dB-Skala (negativ, naeher 0 = lauter)
  • Faustregel-Pegel mit Farb-Code (−45 sensibel, −38 ausgewogen, −25 robust)
  • Klarstellung "niedrigere Zahl = sensibler"

audio.ts:
- VAD_SILENCE_DB_OVERRIDE_KEY in AsyncStorage
- loadVadSilenceDbOverride() liefert null oder Zahl
- startRecording: wenn Override gesetzt, Adaptive-Baseline uebersteuert.
  Speech-Schwelle wird auf Override + 10 dB gesetzt. Toast zeigt
  "VAD: manuell stille>-XX dB"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:24:26 +02:00
duffyduckandClaude Opus 4.7 52795530f9 fix(audio): Wake-Word-Anruf-Pause + Resume-Cooldown + Background-Mic-Order
Bug 4 — Wake-Word laeuft bei Anruf weiter:
phoneCall ruft jetzt wakeWordService.pauseForCall bei RINGING/OFFHOOK
und resumeFromCall bei IDLE. Telefonie-App belegt das Mikro waehrend
des Anrufs, openWakeWord muss daher pausieren. Pre-Call-State wird
gemerkt — armed bleibt armed, conversing degraded zu armed (sonst
landet der User nach Auflegen in einem halben Dialog).

Bug 3 — App-Resume triggert faelschlich Wake-Word:
Beim Wechsel von Background nach Foreground gibt's Audio-Pegel-Spikes
(AudioFocus-Switch, AudioTrack re-route), die openWakeWord als Wake-
Word interpretiert. Neuer Cooldown-Mechanismus: AppState-Listener im
ChatScreen ruft wakeWordService.setResumeCooldown(1500) — Detections
in der Phase werden in onWakeDetected verworfen.

Bug 1 — Background-Aufnahme klappt nicht:
acquireBackgroundAudio('rec') wird jetzt VOR audioService.startRecorder
gerufen, acquireBackgroundAudio('wake') VOR OpenWakeWord.start. Sonst
greifen Androids Background-Mic-Restrictions (ab 11+) — der Service mit
foregroundServiceType=microphone muss zum Zeitpunkt des AudioRecord-
Starts schon aktiv sein, nicht erst per state-change-Listener
asynchron danach.

Bug 2 (VAD manchmal nicht): nicht in diesem Commit, vermutlich
umgebungsabhaengig. Toast zeigt die kalibrierten Schwellen — wenn
das nochmal auftritt, schick mir die Werte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:49:02 +02:00
duffyduck 2eb0b4df90 release: bump version to 0.0.8.1 2026-05-06 23:49:36 +02:00
duffyduckandClaude Opus 4.7 0c18090351 chore: Highlight-Trigger raus + letzte Piper-Reste aufgeraeumt
Highlight-Trigger:
- diagnostic/index.html: Settings-Sektion + Trigger-Liste-Handler raus
- diagnostic/server.js: get_triggers / save_triggers Action-Handler +
  TRIGGERS_FILE Konstante + handleGetTriggers/handleSaveTriggers Funktionen weg
- README.md: highlight_triggers.json aus dem Datenverzeichnis-Diagram entfernt

Die Auswertung war seit Piper-Removal eh tot — die Datei wurde nur noch
geschrieben aber nirgends gelesen.

Piper-Reste:
- bridge/aria_bridge.py: Modul-Docstring auf F5-TTS aktualisiert,
  Ramona/Thorsten-Erwaehnungen raus, Inline-Kommentar zu "Komponenten
  TTS" gefixt
- aria-data/config/AGENT.md: Stimmen-Tabelle (Ramona/Thorsten) durch
  Hinweis auf F5-TTS Voice-Cloning ersetzt
- aria-data/config/BOOTSTRAP.md: gleiche Tabelle weg, Bridge-Beschreibung
  auf "orchestriert STT/TTS via Gamebox-Bridges" geaendert

Erledigt-Eintraege in issue.md + README markiert (historisch erhalten).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:47:55 +02:00
duffyduckandClaude Opus 4.7 d6b54d3247 feat(audio): Background-Service auch fuer Wake-Word + Aufnahme + Doku-Split
Erweitert den Foreground-Service um den microphone-Type damit nicht nur
TTS, sondern auch Wake-Word-Lauschen und aktive Aufnahmen weiterlaufen
wenn die App im Hintergrund ist.

Slot-System (backgroundAudio.ts):
- 'tts'  : ARIA spricht
- 'rec'  : Aufnahme laeuft
- 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
Mehrere Slots koennen unabhaengig acquired/released werden, der Service
laeuft solange mindestens einer aktiv ist. Notification-Text passt sich
dynamisch an den hoechstprioren Slot an (tts > rec > wake).

Wiring (ChatScreen):
- onPlaybackStarted/Finished → 'tts' Slot
- audioService.onStateChange (recording) → 'rec' Slot
- wakeWordService.onStateChange (off→armed/conversing) → 'wake' Slot

AndroidManifest:
- foregroundServiceType="mediaPlayback|microphone" (Pflicht ab Android 14
  fuer Background-Mic-Zugriff)
- FOREGROUND_SERVICE_MICROPHONE Permission

Doku:
- issue.md Erledigt-Sektion in "Bugs / Fixes", "App Features" und
  "Infrastruktur" gesplittet
- README: Background-Service-Beschreibung erweitert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:43:24 +02:00
duffyduckandClaude Opus 4.7 ead28cf09a feat(audio): Foreground-Service haelt TTS am Leben bei minimierter App
ARIAs Antwort wird jetzt auch dann fertig vorgelesen wenn der User die
App im Hintergrund schickt. Vorher hat Android den Prozess kurz nach
dem Minimieren eingefroren — TTS verstummte mitten im Satz.

Native:
- AriaPlaybackService.kt: Service mit foregroundServiceType=mediaPlayback,
  zeigt persistente Notification "ARIA spricht — antippen oeffnet die App"
  (channel low-priority, ongoing, tap → MainActivity)
- BackgroundAudioModule.kt: RN-Bridge mit start()/stop()
- AndroidManifest: FOREGROUND_SERVICE + FOREGROUND_SERVICE_MEDIA_PLAYBACK
  + POST_NOTIFICATIONS Permissions, Service deklariert

JS:
- backgroundAudio.ts: idempotenter Wrapper (active-Flag verhindert
  doppelte start/stop calls)
- ChatScreen onPlaybackStarted → startBackgroundAudio
- ChatScreen onPlaybackFinished → stopBackgroundAudio
- audio.ts stopPlayback ruft auch stopBackgroundAudio damit die
  Notification bei Cancel/Barge-In/Anruf nicht haengen bleibt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:37:46 +02:00
duffyduckandClaude Opus 4.7 f682aad4ff fix(wake-word): manueller Mikro-Stop beendet Konversation, zurueck zu armed
Bug: Nach Wake-Word "Computer" → conversing → User drueckt manuell den
Mikro-Button um zu stoppen → Audio wird gesendet, aber state bleibt
'conversing'. Nach ARIAs Antwort oeffnet sich automatisch wieder das
Mikro fuer Multi-Turn — obwohl der User explizit den Knopf gedrueckt
hat um zu signalisieren "ich bin fertig".

Fix: Im handleVoiceRecording (= manueller Stop ueber VoiceButton) wird
nach dem Send wakeWordService.endConversation() gerufen wenn aktuell
in conversing-State. Das setzt zurueck auf 'armed' und startet
openWakeWord wieder fuer passives Lauschen. ARIAs Antwort kommt durch,
TTS spielt, aber resume() ist dann no-op weil state schon 'armed'.

Bei VAD-Auto-Stop (silence-callback im Wake-Word-Pfad) bleibt das
Multi-Turn-Verhalten unveraendert — das ist die "natuerliche" Pause
und passt zum Konversations-Modus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:33:31 +02:00
duffyduckandClaude Opus 4.7 e0c1a4bcd5 feat: GPS-Position bei jeder Nachricht an aria-core (still, nur bei Bedarf)
App: GPS-Toggle in Settings → Allgemein → Standort wird jetzt korrekt
in AsyncStorage persistiert (key: aria_gps_enabled). ChatScreen pollt
den Wert mit den anderen Settings im 2s-Intervall.

Bridge: chat/audio-Handler nutzen jetzt einen gemeinsamen _build_core_text
Helper, der je nach Kontext einen Hint vorschaltet:
- Barge-In ("[Hinweis: Stefan hat dich unterbrochen ...]")
- GPS    ("[Stefans aktuelle GPS-Position: lat, lon. Nutze die nur wenn
          die Frage sich auf seinen Standort bezieht. Erwaehne sie nicht
          von dir aus, ausser er fragt explizit danach.]")

ARIA weiss bei "wo bin ich?" / "Wetter hier?" automatisch was zu tun ist
— bei normalen Fragen kommt die Position aber nicht ungefragt vor. Der
User sieht im Chat-Verlauf nichts von der GPS-Info, nur ARIAs Antwort
kann darauf eingehen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:29:34 +02:00
duffyduck a648dad96d release: bump version to 0.0.8.0 2026-05-06 23:06:22 +02:00
duffyduckandClaude Opus 4.7 da5579038e fix(vad): adaptive Baseline robuster — minimum + Cap-Bereich
Bug: Wenn beim Aufnahmestart sofort gesprochen wurde (z.B. Wake-Word-
Echo noch im Mikro) ODER der Hintergrund vorruebergehend laut war,
verschob die avg-basierte Baseline die Stille-Schwelle so weit nach
oben, dass normale Hintergrundgeraeusche dauerhaft als "Sprache"
zaehlten — VAD feuerte nie, Aufnahme lief unendlich.

Fix:
- Baseline = MINIMUM der 5 Samples statt Mittelwert (ruhigster Moment)
- Cap auf sinnvollen Bereich:
  - Silence-Schwelle: -50dB bis -28dB (vorher unbegrenzt)
  - Speech-Schwelle:  -40dB bis -18dB
- Erweitertes Log: zeigt sowohl raw als auch geclamp-te Werte

Damit gibt's keine "tote" VAD-Konfiguration mehr — selbst wenn die
Baseline-Messung Schrott ist, bleiben die Schwellen praktikabel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:05:08 +02:00
duffyduck 4ba48940b9 release: bump version to 0.0.7.9 2026-05-06 23:00:32 +02:00
duffyduckandClaude Opus 4.7 568ef9ed10 fix(audio): STT-Cleanup-Timeout skaliert mit Aufnahmedauer
Der pauschale 30s-Timeout vom Vorgaenger-Commit haette bei einer
5-Minuten-Aufnahme schon getriggert waehrend Whisper noch transkribiert
(Whisper braucht auf der Gamebox-GPU grob real-time/5, plus Bridge-
Roundtrip).

Neue Formel: 60s Buffer + 1x Aufnahmedauer.
- 5s Aufnahme → 65s Wait
- 5min Aufnahme → 6 min Wait
- 30min Aufnahme → 31 min Wait

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:59:20 +02:00
duffyduck 7682a0ce58 release: bump version to 0.0.7.8 2026-05-06 22:58:20 +02:00
duffyduckandClaude Opus 4.7 3ca834e633 fix(audio): Auto-Removal von Sprachnachrichten ohne STT-Result nach 30s
Bug: Wenn eine Aufnahme leer war, nur Wake-Word-Echo enthielt oder STT
sonstwie nichts erkannt hat, sendet die Bridge KEIN stt-Event zurueck —
die Placeholder-Bubble "Spracheingabe wird verarbeitet" blieb fuer immer
im Chat. Folge-Aufnahmen matchten dann via Substring-Fallback die ALTE
Placeholder, der echte Text landete in der falschen Bubble.

Fix: nach jedem audio-send einen 30s-Timer starten. Wenn nach Ablauf die
Bubble (per audioRequestId identifiziert) immer noch "verarbeitet" ist,
wird sie entfernt + Toast "nicht erkannt" zeigt das dem User.

So bleibt der State sauber + audioRequestId-Match auf zukuenftige
Aufnahmen findet die richtige Bubble (statt die hinterbliebene Placeholder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:57:20 +02:00
duffyduck 55ef207454 release: bump version to 0.0.7.7 2026-05-06 22:52:23 +02:00
duffyduckandClaude Opus 4.7 6651f5937d feat(audio): Wake-Word parallel zu TTS mit AcousticEchoCanceler
Du kannst jetzt "Computer" sagen waehrend ARIA noch redet — TTS
verstummt, neue Aufnahme startet. Vorher musste man warten oder
manuell den Voice-Button tappen.

Native (OpenWakeWordModule.kt):
- AudioRecord-Source von MIC auf VOICE_COMMUNICATION (aktiviert auf
  den meisten Geraeten Echo-Cancellation + Noise-Suppression)
- Zusaetzlich AcousticEchoCanceler/NoiseSuppressor/AutomaticGainControl
  explizit aktiviert wenn vorhanden — robuster auf Geraeten wo die
  VOICE_COMMUNICATION-Source die Effects nicht automatisch mitbringt
- releaseAudioEffects() im stop/dispose

JS (wakeword.ts):
- Neue API: startBargeListening / stopBargeListening — Wake-Word
  parallel aktivieren, ohne den State 'conversing' zu verlassen
- onWakeDetected unterscheidet jetzt: in 'conversing' → barge-in-
  Callback (nicht der normale wake-callback). Sonst Standard-Pfad.
- onBargeIn-Subscriber-API + isBargeListening-Getter

Lifecycle-Wiring (audio.ts + ChatScreen):
- audioService.onPlaybackStarted callback (neu)
- ChatScreen: Bei TTS-Start → wakeWord.startBargeListening
- ChatScreen: Bei TTS-Ende → wakeWord.stopBargeListening (sonst kein
  AudioRecord fuer die naechste Aufnahme)
- ChatScreen: Bei BargeIn → haltAllPlayback + cancel_request +
  150ms-Pause + neue Aufnahme starten

issue.md + README aktualisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:50:09 +02:00
duffyduckandClaude Opus 4.7 e9e7dd804f docs: issue.md + README mit audioRequestId-Fix + Bereit-Sound aktualisiert
issue.md: drei neue Erledigt-Eintraege (Placeholder-Race per
audioRequestId, Mikro-Offen-Toast erst nach Recording-Start, Bereit-
Sound mit Toggle). Neuer Offen-Eintrag: Wake-Word parallel zu TTS
mit AcousticEchoCanceler.

README: Wake-Word-Bedienung erweitert um Ding-Dong + "🎤 sprich
jetzt"-Toast. Roadmap mit den beiden neuen Features ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:44:03 +02:00
duffyduck ec9530f17f release: bump version to 0.0.7.6 2026-05-06 22:41:55 +02:00
duffyduckandClaude Opus 4.7 97cb7be313 feat(audio): "Bereit"-Sound (Ding-Dong) wenn Mikro nach Wake-Word offen ist
Kurzer akustischer Hinweis (Airplane Ding-Dong, 20KB MP3) bei
audioService.startRecording-Erfolg im Wake-Word-Pfad — User weiss
exakt ab wann er reden darf, statt das Toast nur zu sehen.

Quelldatei: android/sounds/Airplane-ding-dong.mp2 → ffmpeg-konvertiert
zu MP3 64kbps, abgelegt in android/app/src/main/res/raw/ damit Android
sie als Resource laden kann.

Toggle in App-Settings → Wake-Word, default aktiv. Bei Aktivierung
spielt direkt eine Vorschau ab damit man weiss wie's klingt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:40:45 +02:00
duffyduckandClaude Opus 4.7 77e927ffcd fix(audio): Placeholder-Race per audioRequestId + Mikro-Offen-Toast erst nach Start
Bug: Bei zwei Sprachnachrichten kurz hintereinander wurde der STT-Text
der zweiten in die Bubble der ersten geschrieben. Ursache: findIndex
matchte ueber Substring "Spracheingabe wird verarbeitet" → bei zwei
offenen Placeholders nahm er immer die ERSTE, egal welches STT-Result
gerade kam.

Fix: jede Aufnahme bekommt eine eindeutige audioRequestId, App pusht
sie in die Placeholder-Bubble + ans audio-Event. Bridge gibt sie
unveraendert ans STT-Result zurueck. App matcht primaer per ID, fallback
auf Substring (Kompatibilitaet zu alten Bridge-Versionen).

Bonus: Toast "Wake-Word erkannt" entfernt, dafuer "🎤 Mikro offen —
sprich jetzt" erst wenn audioService.startRecording wirklich erfolgreich
war. So weiss der User exakt ab wann er reden darf — vorher war der Toast
schon ~400ms vorher da.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:33:26 +02:00
duffyduck a9a87f12df release: bump version to 0.0.7.5 2026-05-06 22:15:49 +02:00
duffyduckandClaude Opus 4.7 2a56ac0290 docs: issue.md + README aktualisiert mit aktuellen Features
issue.md: openWakeWord, ABI-Split, Underrun-Schutz, Conversation-Focus,
PhoneStateListener, Voice-Override-Fix, Bild+Text-Merge, Diagnostic-UI,
adaptive VAD, Max-Aufnahme konfigurierbar, Barge-In, Push-to-Talk-Refactor,
Settings-Sub-Screens, Textauswahl-Fix in Erledigt verschoben.
Porcupine-bezogene offene Bugs entfernt (Engine gewechselt). Neue Offene:
STT-Placeholder-Replacement, Custom-onnx-Upload, Pause+Resume bei Anruf.

README: Push-to-Talk-Erwaehnung raus, VAD-Beschreibung auf adaptiv +
neuen Default 5min, neue Bullets fuer Barge-In + Anruf-Pause, Roadmap
ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:13:53 +02:00
duffyduckandClaude Opus 4.7 edc65ce645 feat(settings): Sub-Screen-Navigation statt langer Liste
Settings ist jetzt ein Hauptmenue mit 8 Kategorien — beim Tippen klappt
nur die gewaehlte Sektion auf, "<-Back-Button kehrt zur Uebersicht zurueck.

Gruppen:
- 🔌 Verbindung      (Server, Token, Status, Verbindungslog)
- ⚙️ Allgemein       (Betriebsmodus, GPS-Standort)
- 🎙️ Spracheingabe   (Stille-Toleranz, Aufnahmedauer)
- 👂 Wake-Word       (Wake-Word-Auswahl)
- 🔊 Sprachausgabe   (Stimmen, Pre-Roll, Geschwindigkeit)
- 📁 Speicher        (Anhang-Speicherort, Auto-Download)
- 📜 Protokoll       (Privatsphaere, Backup)
- ℹ️ Ueber           (App-Version, Update)

Implementierung absichtlich ohne react-navigation-Stack: ein einzelner
currentSection-State, conditional rendering pro Sektion. So bleibt aller
geteilte State (rvs.config, voices-Liste, Toggles) im selben Component-
Closure ohne props-drilling oder Context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:08 +02:00
duffyduckandClaude Opus 4.7 d7efaf93b3 refactor(voice): Push-to-Talk-Pfad raus, nur Tap-to-Talk
handlePressIn/Out + onResponderGrant/Release/Terminate weg. Push-to-
Talk lief parallel zu Tap-to-Talk und triggerte je nach Touch-Timing
unkontrollierbar. Stefan kennt das Verhalten ohnehin nicht (sagt
"druecken startet, druecken stoppt") — Push-to-Talk macht UX nur
unklarer ohne Mehrwert.

isLongPress-Ref entfernt (war nur fuer Push-to-Talk-Discrimination).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:02:21 +02:00
duffyduckandClaude Opus 4.7 31ff20c846 feat: Max-Aufnahmedauer konfigurierbar + Barge-In gibt aria-core Kontext
Max-Aufnahme:
Default rauf von 2 auf 5 Minuten, in den App-Settings konfigurierbar
zwischen 1 und 30 Minuten (loadMaxRecordingMs aus AsyncStorage,
Storage-Key aria_max_recording_sec). Notbremse-Verhalten bleibt:
nach Ablauf wird die Aufnahme automatisch beendet und gesendet.

Barge-In Kontext:
Wenn der User waehrend ARIA noch redet/arbeitet eine neue Sprach-
oder Text-Nachricht sendet, geht jetzt ein 'interrupted: true' Flag
mit. Bridge praefixed den Text fuer aria-core dann mit:

  "[Hinweis: Stefan hat dich gerade unterbrochen waehrend du noch
  gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur,
  Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"

So weiss ARIA dass die neue Message KEINE eigenstaendige Folgefrage
ist sondern auf den abgebrochenen Run bezogen. Der User sieht in
seinem Chat nur den reinen Text — der Hint geht nur an aria-core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:58:11 +02:00
duffyduckandClaude Opus 4.7 406f4cb3cc fix: Textauswahl, adaptive VAD-Schwelle + Barge-In bei Sprachaufnahme
Bug 1 — Textauswahl in Bubbles ging nicht mehr:
MessageText hatte verschachtelte <Text onPress={...}> fuer Custom-Link-
Styling. Das fing die Long-Press-Geste ab, daher kein Markieren+Kopieren
mehr. Jetzt nur noch ein einzelnes <Text selectable dataDetectorType="all">,
Android macht URLs/Telefonnummern/Emails per System-Detection klickbar.

Bug 2 — VAD erkannte Stille nicht zuverlaessig (Aufnahme lief endlos):
Festwerte (-45dB Stille / -28dB Sprache) passten nicht zu jeder Umgebung.
In lauteren Raeumen lag der Hintergrundpegel ueber der Stille-Schwelle,
lastSpeechTime wurde dauerhaft aktualisiert → VAD feuerte nie, Aufnahme
lief bis 120s Max-Duration.

Jetzt adaptiv: erste 5 Mic-Samples (~500ms) bilden die Baseline; Stille-
Schwelle = baseline+6dB, Sprache-Schwelle = baseline+12dB. Toast zeigt
die kalibrierten Werte beim Aufnahmestart. Fallback auf -38dB/-22dB falls
das Mikro keine Metering-Updates liefert.

Bug 3 — Barge-In ("ach vergiss es"):
Wenn waehrend ARIAs Antwort eine neue Sprachnachricht aufgenommen wird,
wird ARIAs aktuelle Aktivitaet (TTS + thinking/tool) sofort abgebrochen
bevor die neue Message gesendet wird — wie in einem echten Gespraech wo
man den anderen unterbrechen darf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:49:48 +02:00
duffyduck fa0667088a release: bump version to 0.0.7.4 2026-05-06 20:30:37 +02:00
duffyduckandClaude Opus 4.7 f55329706e debug(stt): Toasts in App + Bridge-Log fuer STT-Broadcast-Erfolg
Da kein adb-Zugriff: visuelle Debug-Pfade direkt in der App + im
Diagnostic-Bridge-Tab.

App: zwei Toasts beim Empfang eines stt-events
- "STT empfangen: ..." sobald das chat-event mit sender=stt reinkommt
- "Bubble #X ersetzt" oder "keine Placeholder → neue Bubble"

Bridge: explizites Info-Log "STT-Text an RVS broadcastet (sender=stt)"
nach erfolgreichem _send_to_rvs, "NICHT broadcastet" wenn die Methode
False lieferte (Ping fehlgeschlagen / Verbindung tot).

Naechster Test:
- Sprachnachricht aufnehmen
- Toast erscheint? → STT-Event kommt in App an, Bug ist im findIndex
- Toast erscheint nicht? → Diagnostic Bridge-Tab pruefen ob das Log
  "STT-Text an RVS broadcastet" steht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:29:26 +02:00
duffyduck 6c7fd1d0e3 release: bump version to 0.0.7.3 2026-05-06 20:12:01 +02:00
duffyduck 9d8db111ac release: bump version to 0.0.7.2 2026-05-05 14:51:18 +02:00
duffyduckandClaude Opus 4.7 482cb6ace3 fix(compose): $(find ...) muss in compose-command zu $$(find ...) escaped werden
docker-compose interpretiert $( als Variable-Interpolation-Pattern und
warf "Invalid interpolation format". Die anderen $$DIST-Stellen waren
schon korrekt escaped, nur das command-substitution fehlte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:24:31 +02:00
duffyduckandClaude Opus 4.7 69c1c49a7d fix(diagnostic+app): Chat-UI bubblig, mehrzeilig + persistente RVS + STT-Logs
Diagnostic-UI:
- chat-msg ist jetzt eine richtige Bubble (border-radius 14px, Schatten,
  flex-Layout statt margin-Hack, Tail-Radius zur Sender-Seite hin).
- Eingabefelder (haupt + Vollbild) jetzt textarea mit Auto-Resize.
  Enter sendet, Shift+Enter macht neue Zeile.
- white-space: pre-wrap behaelt Zeilenumbrueche aus dem Text bei.

Diagnostic-Server:
- sendToRVS_raw nutzt jetzt die persistente rvsWs statt fuer jedes Send
  eine frische Verbindung aufzubauen. Der frische-WS-Pfad hatte Race-
  Probleme (WS schloss bevor RVS broadcasten konnte → User-Nachrichten
  von Diagnostic kamen nicht in der App an). Frische WS bleibt als
  Fallback wenn die persistente gerade tot ist.

App:
- console.log am Anfang des chat-handlers + im STT-Result-Handler mit
  findIndex-Result und Placeholder-Count. Bei nicht-erkanntem STT-Text
  liefert `adb logcat -s ReactNativeJS:V` jetzt direkt den Befund:
  kommt das Event ueberhaupt an, findet er die Placeholder?

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:19:16 +02:00
duffyduck b1ccf29295 release: bump version to 0.0.7.1 2026-05-03 22:11:08 +02:00
duffyduck 4cd9faece2 release: bump version to 0.0.7.0 2026-05-03 21:59:38 +02:00
duffyduckandClaude Opus 4.7 fec8aa977b feat(audio): TTS pausiert bei Anruf + Conversation-Focus haelt Spotify durchgehend gepaust
Bug 1a — Anruf-Pause:
Neues PhoneCallModule.kt nutzt TelephonyCallback (API 31+) bzw.
PhoneStateListener (Pre-12) um auf RINGING/OFFHOOK/IDLE zu reagieren.
Bei Klingeln/Gespraech ruft phoneCall.ts → audioService.haltAllPlayback,
ARIA verstummt sofort. READ_PHONE_STATE Permission wird beim ersten
Start angefragt; ohne Permission failt der Listener leise.

Bug 1b — Spotify-Resume:
AudioFocus wird jetzt an den Conversation-Lifecycle gekoppelt statt an
einzelne Streams. Solange wakeWordState 'conversing' ist, blockt
acquireConversationFocus() jeden per-Stream-Release. Erst beim Wechsel
auf 'armed'/'off' darf der Focus tatsaechlich freigegeben werden.
Verhindert das "Spotify kommt nach 10s wieder hoch"-Phaenomen auch
ueber Render-Pausen + zwischen mehreren ARIA-Antworten hinweg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:44:58 +02:00
duffyduckandClaude Opus 4.7 20123de827 fix: Sprachnachricht-Bubble defensiv + Bild+Text als eine Anfrage
Bug 2: STT-Result schreibt jetzt eine neue User-Bubble wenn keine
Placeholder im State gefunden wird (statt das Update zu verwerfen).
Schuetzt vor Race-Conditions zwischen audio-send und State-Updates,
damit der gesprochene Text immer im Chat erscheint.

Bug 3: Bild + Text wurden als zwei getrennte Events ('file' + 'chat')
gesendet, jeder triggerte einen eigenen send_to_core. ARIA antwortete
zweimal — einmal "warte auf Anweisung" beim Bild, dann nochmal auf
den Text. Bridge buffert jetzt eingehende file-Events 800ms; kommt in
dem Fenster ein chat, werden alle Files + Text zu einer einzigen
aria-core-Nachricht gemerged. Kein chat → Files alleine wie bisher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:40:15 +02:00
duffyduck 8761d1a1b7 release: bump version to 0.0.6.9 2026-05-01 00:08:08 +02:00
duffyduckandClaude Opus 4.7 abc5b971f4 fix(voice): Stimmen-Wechsel greift wieder — Override bleibt bis naechster Chat-Event
Bug: Voice-Override wurde nach der ersten ARIA-Antwort konsumiert.
Eine ARIA-Antwort triggert aber oft mehrere TTS-Calls (Tool-Use →
Zwischenmeldung → finale Antwort). Der erste nutzte die neue Stimme,
alle folgenden fielen auf self.xtts_voice (= alte Voice aus
voice_config.json) zurueck. Die App schickt nie ein config-Update,
daher blieb voice_config.json fuer immer auf der alten Stimme.

Neue Semantik:
- chat-/audio-Event mit voice="X" → Override="X", gilt fuer alle
  folgenden TTS-Calls bis zum naechsten chat-Event
- chat-Event mit voice="" → Override geloescht, fallback auf
  Default-Voice (voice_config.json / Diagnostic)
- chat-Event ohne voice-Field → Override unveraendert

Audio-Send in ChatScreen.tsx (Push-to-Talk-Pfad) gab voice/speed
gar nicht mit; jetzt konsistent mit dem Tap-to-Talk-Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:04:19 +02:00
duffyduck b588dd7e3b release: bump version to 0.0.6.8 2026-04-26 13:26:00 +02:00
duffyduckandClaude Opus 4.7 309df9d851 fix(wake-word): Embedding-Output ist rank-4, nicht rank-2 — Trigger funktioniert jetzt
Hauptursache warum kein Wake-Word je triggerte: das Google-Speech-
Embedding-Modell liefert (1,1,1,96), nicht (1,96). Der Cast
`as Array<FloatArray>` warf eine ClassCastException, die vom try/catch
geschluckt wurde — Pipeline lief still ins Leere.

Zusaetzlich:
- WW-Input-Frame-Count wird jetzt aus den Modell-Metadaten gelesen
  (variiert pro Keyword; hey_jarvis=16, computer_v2evtl. anders)
- "Computer" als Wake-Word erweitert (Community-Modell aus
  fwartner/home-assistant-wakewords-collection)

"ARIA" als Wake-Word: gibt's nicht fertig trainiert. Muesste ueber
das openWakeWord Colab-Notebook trainiert werden (~1h auf gratis-GPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:24:47 +02:00
duffyduckandClaude Opus 4.7 f2e643d1fb fix(app): Underrun-Schutz im PcmStreamPlayer — Spotify resumed nicht mehr nach 10s
Wenn die Bridge zwischen zwei Saetzen rendert (1-2s pro Satz auf der
Gamebox-RTX 3060), kommen keine neuen PCM-Chunks rein und der AudioTrack-
Buffer laeuft leer. Spotify hat eine eigene Heuristik die nach ~10s
"stummer Lücke" eigenmaechtig die Wiedergabe wiederaufnimmt — auch wenn
wir den AudioFocus formal noch halten.

Fix: Writer-Thread fuettert Stille rein wenn der Puffer unter ~100ms
faellt (~50ms pro Refill-Tick alle 50ms). AudioTrack bleibt damit
durchgehend aktiv, andere Apps respektieren weiterhin den Fokus.

Bonus: 30s-Idle-Cutoff falls die Bridge crashed und kein final-Marker
mehr kommt — sonst wuerde der Writer-Thread ewig Stille fuettern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:18:25 +02:00
duffyduck 6ac374621c release: bump version to 0.0.6.7 2026-04-26 13:08:13 +02:00
duffyduckandClaude Opus 4.7 efbd306597 build(android): ABI-Split auf arm64-v8a — APK von 136 MB auf ~35 MB
Mit ONNX Runtime fuer das Wake-Word kommen Native-Libs fuer alle 4
Architekturen rein (arm64-v8a, armeabi-v7a, x86, x86_64). Das
sprengt sowohl den Gitea-Upload (nginx-Limit) als auch unnoetig die
Auto-Update-Downloads aufs Phone. Per ABI-Split jetzt nur noch
arm64-v8a — deckt jedes Android-Phone seit 2017 ab.

build.sh greift den neuen APK-Pfad (app-arm64-v8a-release.apk),
faellt auf app-release.apk zurueck falls die Splits in build.gradle
deaktiviert werden.

versionCode 606 / versionName 0.0.6.6 (vom Linter mitgehoben).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:04:32 +02:00
duffyduck 4454613a98 release: bump version to 0.0.6.6 2026-04-26 12:59:26 +02:00
duffyduckandClaude Opus 4.7 55cfb752a2 feat(app): Wake-Word komplett on-device via openWakeWord (ONNX)
Picovoice/Porcupine raus — neuer Stack ist openWakeWord (Apache 2.0,
on-device, ONNX Runtime). Kein API-Key, keine Lizenzgebuehren, Audio
verlaesst das Geraet nicht. Eigene Wake-Words sind via openWakeWord-
Notebook gratis trainierbar.

Pipeline (alles im OpenWakeWordModule.kt):
  1. AudioRecord 16kHz mono int16 in 1280-Sample-Chunks (80ms)
  2. melspectrogram.onnx → 32-mel Frames (mel/10 + 2 wie in Python)
  3. embedding_model.onnx, 76-Frame Sliding Window (stride 8) → 96-dim
  4. hey_jarvis.onnx (oder anderes Keyword) auf letzten 16 Embeddings
  5. Sigmoid-Score, threshold/patience/debounce-Filter
  6. RN-Event "WakeWordDetected" raus

Mitgelieferte Modelle in assets/openwakeword/: hey_jarvis (Default),
alexa, hey_mycroft, hey_rhasspy. Externe Service-API (start/stop/
configure/onWakeWord/...) bleibt identisch — ChatScreen unveraendert.

build.gradle: com.microsoft.onnxruntime:onnxruntime-android:1.17.1
package.json: @picovoice/porcupine-react-native + voice-processor raus
SettingsScreen: AccessKey-Feld weg, neue Keyword-Liste mit Labels
README: Wake-Word-Sektion komplett umgeschrieben (kein Picovoice mehr)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:56:33 +02:00
duffyduck a4d3449e3a release: bump version to 0.0.6.5 2026-04-25 22:51:00 +02:00
duffyduckandClaude Opus 4.7 44d2c6b4fe fix(app): Spotify-Bounce zwischen ARIA-Antworten + Wake-Word-Doku
AudioFocus wird jetzt mit 800ms Verzoegerung freigegeben — wenn ARIA
direkt eine zweite Antwort hinterherschickt oder das Recording ins TTS
uebergeht, wird das Release abgebrochen. Spotify/YouTube haben damit
keine Mikro-Sekunden-Luecke mehr zum Hochkommen waehrend ARIA spricht.

README: neue Sektion zur Wake-Word-Einrichtung mit Picovoice
(7-Tage-Trial, Console-Link, Anleitung fuer eigene Keywords) und
veraltete Wake-Word-Limitation entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:49:45 +02:00
duffyduck 0309c95aa5 release: bump version to 0.0.6.4 2026-04-25 20:58:10 +02:00
duffyduckandClaude Opus 4.7 2aa2cc70c9 debug: explicit speed-Log direkt vor F5-TTS infer()-Call
Damit man am Logfile schwarz auf weiss sieht ob der Wert wirklich an
die Library geht — falls bei Stefan speed=0.30 ankommt aber Maia
trotzdem schnell, ist's F5-TTS-Verhalten, nicht Pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:45:16 +02:00
duffyduckandClaude Opus 4.7 9d0776c819 fix: Text-Auswahl in MessageText — selectable an alle nested Texts
Android-Eigenheit: bei nested Text-Komponenten muss selectable=true
auch an die Kinder; der Wert auf dem Parent erbt sich nicht zuverlaessig.
Plus: dataDetectorType="all" als Fallback fuer System-Linkifizierung,
falls unsere Regex einen Match verpasst.

suppressHighlighting=false damit Long-Press auf den Link-Texten den
Selection-Mode nicht blockt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:41:20 +02:00
duffyduck f031fa159e release: bump version to 0.0.6.3 2026-04-25 20:35:25 +02:00
duffyduckandClaude Opus 4.7 be373466a3 fix: klares UI-Feedback fuer Wake-Word-State
Stefan's Verwirrung: Ohr-Button + KEIN Porcupine = Direkt-Aufnahme,
nicht passives Lauschen. Wenn er lange wartet, schnappt das Mikro
Hintergrundgeraeusche/Sprache auf, sendet ab, Ohr aus. Sah aus wie
"Wake-Word triggerte" — war aber stinknormales Recording.

Fixes fuer klares Feedback:
- Toast bei jedem State-Wechsel:
  * Direkt-Aufnahme (kein Porcupine): "Wake-Word nicht aktiv —
    direkte Aufnahme startet (Mikro hoert mit)"
  * armed: "Lausche auf X..."
  * Wake erkannt: "Wake-Word X erkannt — sprich jetzt"
  * endConversation: "Lausche wieder auf X" oder "Mikro aus"
- Ohr-Button-Icon zeigt drei States:
  🔇 off
  👂 armed (Porcupine lauscht passiv)
  🎙️ conversing (aktive Aufnahme laeuft)
- ChatScreen subscribed wakeWordService.onStateChange fuer Live-
  Updates des Icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:34:07 +02:00
duffyduckandClaude Opus 4.7 bbf9aed3ba fix: 4 Bugs — STT-Mapping, Speed-Logging, VAD-Logs, Wake-Word-Toast
Bug 2: STT-Result ueberschrieb beide noch unaufgeloeste Audio-Bubbles
mit gleichem Text. Fix: nur die ERSTE matchende Bubble aktualisieren
(findIndex + index-Update statt map). Reihenfolge ist FIFO weil Whisper
sequenziell verarbeitet.

Bug 3: Speed-Param wird nun in jedem Hop geloggt:
  - ChatScreen: "[Chat] sende mit voice=X speed=Y"
  - aria-bridge: "XTTS-Request gesendet (voice=X, speed=Y.YYx)"
  - f5tts-bridge: "F5-TTS: N Satz(e), voice=X, speed=Y.YYx"
Damit kann man im logcat/docker-logs eindeutig sehen wo speed evtl.
verloren geht oder ob die Stimme einfach von Natur aus schnell ist.

Bug 4: VAD-Trigger-Reason mit Schwelle: "VAD NNN ms Stille (Schwelle=NNN ms)".
Plus startRecording loggt jetzt VAD-Stille + MAX-Recording.

Bug 1 (Porcupine): mehr Debug + Toast-Meldungen.
  - init failure: err.name/code/stack ins Log
  - start() ohne Porcupine: Toast "Access Key in Settings setzen"
  - start() Fehler: Toast mit Fehlermeldung
  - configure(): Toast wenn init scheitert
  - Erfolgreiches arming: Toast "Lausche auf X"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:28:46 +02:00
duffyduck 745b4a07c0 release: bump version to 0.0.6.2 2026-04-25 20:20:25 +02:00
duffyduckandClaude Opus 4.7 23ca815cb2 fix: handlePcmChunk serialisiert — fixes Race bei kurzen Streams
Bei kurzen Saetzen (nur ein paar Chunks + sofort final) konnten die
async handlePcmChunk-Calls parallel laufen. Der final-Chunk konnte
native end() aufrufen BEVOR der vorherige Chunk seinen native start()
abgeschlossen hatte. Der Writer-Thread startete dann mit endRequested
bereits true, verarbeitete keine Chunks sauber → Audio ging verloren.

Fix: Wrapper chaint alle Chunk-Calls an eine Promise-Queue:
  _pcmChunkQueue = Promise.resolve()
  handlePcmChunk → _pcmChunkQueue.then(() => _handlePcmChunkImpl(p))

So werden start/writeChunk/end garantiert in der richtigen Reihenfolge
verarbeitet. Der API-Contract bleibt (gleiches return-Promise).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:58:27 +02:00
duffyduck cc3fac8142 release: bump version to 0.0.6.1 2026-04-25 01:24:31 +02:00
duffyduckandClaude Opus 4.7 cd89e36ec2 fix: alte APKs im Cache werden jetzt aufgeraeumt
Die heruntergeladenen Update-APKs (~20-30MB pro Release) landeten in
CachesDirectoryPath und wurden nie geloescht. Bei regelmaessigen
Updates sammelt sich das auf mehrere 100MB an.

Fix: cleanupOldApks() wird gerufen
  - einmal beim App-Start (Constructor) — alte APKs sind sowieso nicht
    mehr relevant, die aktuelle Version laeuft ja aus dem System
  - vor jedem neuen Download — falls jemand zwei Updates in einer
    Session zieht

Loescht alle *.apk Dateien im CachesDirectoryPath und loggt die
freigemachte Groesse pro Datei.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:22:22 +02:00
duffyduck f5b4285d15 release: bump version to 0.0.6.0 2026-04-25 01:13:42 +02:00
duffyduckandClaude Opus 4.7 248e7c9ae4 fix: preroll=0 wirklich sofort + Trailing-Silence gegen Wort-Cutoff
Zwei Bugs die zusammen dafuer sorgen dass Worte "verschluckt" werden:

1) play() wurde bei preroll=0 erst beim ersten echten Chunk aufgerufen
   — nicht schon nach der Leading-Silence. Dadurch musste AudioTrack
   gleichzeitig Startup UND Audio abspielen, die Hardware-Anfahr-Latenz
   schluckt die ersten Samples.

   Fix: Bei prerollBytes==0 direkt nach dem silence-write play() rufen.
   AudioTrack haelt den Play-State und wartet auf mehr Samples — die
   naechsten Chunks kommen in den bereits laufenden Stream rein.

2) Nach letztem Chunk ging der Writer via return@Thread in den finally-
   Block. Der wartete zwar auf playbackHeadPosition >= totalFrames, aber
   Android's Hardware-Pipeline puffert oft noch ein paar Samples nach —
   stop() kam, Samples futsch.

   Fix: 300ms TRAILING_SILENCE am Ende schreiben. playbackHeadPosition
   erreicht echt bis zum Ende der echten Samples bevor die Stille abspielt.
   Loop umgeschrieben auf mainLoop-Label (break statt return@Thread) damit
   Trailing-Silence garantiert laeuft.

LEADING_SILENCE auf 300ms erhoeht fuer bessere AudioTrack-Warmup-Toleranz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:11:23 +02:00
duffyduck 7058cc8d8d release: bump version to 0.0.5.9 2026-04-25 01:04:00 +02:00
duffyduckandClaude Opus 4.7 7919489543 feat: Pre-Roll-Buffer kann jetzt auf 0 (sofort abspielen)
F5-TTS ist schnell genug dass der Puffer bei kurzen Saetzen eher
schadet als nuetzt — er verzoegert den play()-Start fuer Sekunden die
dann als Wartezeit auffallen.

Aenderungen:
- audio.ts: TTS_PREROLL_MIN_SEC 1.0 → 0 (Einstellbar in Settings)
- PcmStreamPlayerModule.kt: MIN_PREROLL_SECONDS auf 0.0, Fallback-
  Logic respektiert jetzt 0 als gueltigen Wert (vorher hat der
  .let { if (it > 0) it else DEFAULT } 0 zu 3.5s umgebogen).

Bei preroll=0 greift der Leading-Silence von 200ms immer noch, d.h.
AudioTrack-Startup bleibt sauber. play() wird dann beim allerersten
echten PCM-Chunk aufgerufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:02:48 +02:00
duffyduckandClaude Opus 4.7 feac7f2479 feat(diagnostic): Speed-Slider im Voice-Preview-Modal (nicht persistiert)
Neue −0.1 / +0.1 Buttons im Preview-Modal mit aktuellem Wert-Label.
Bei jedem Oeffnen wird der Speed auf 1.0 zurueckgesetzt (bewusst kein
persist — nur zum Experimentieren waehrend das Modal offen ist).

- Range 0.1-5.0, gleiche wie in App-Settings
- Wird beim Play an f5tts-bridge als speed-Param mitgegeben
- Server clampt auf 0.1-5.0, Fallback 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:54:40 +02:00
duffyduck b80b813703 release: bump version to 0.0.5.8 2026-04-25 00:51:13 +02:00
duffyduckandClaude Opus 4.7 e7bb6c37cb feat: Sprechgeschwindigkeit-Range auf 0.1-5.0 erweitert
TTS_SPEED_MIN 0.5 → 0.1, TTS_SPEED_MAX 2.0 → 5.0.
Bridge-seitige Validierungen (aria_bridge.py + f5tts/bridge.py) mit-
gezogen auf den gleichen Bereich.

Hinweis: Extremwerte (unter 0.5 oder ueber 2.0) koennen bei F5-TTS
verzerrte Ausgaben produzieren — Stefan bekommt die Freiheit zum
Experimentieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:49:05 +02:00
duffyduckandClaude Opus 4.7 d146ca92c4 fix: Aufnahme-Crashes/Double-Tap durch VAD-Multi-Fire + stale closure
Drei zusammenhaengende Bugs:

1. VAD-Timer feuerte im 200ms setInterval WEITER nachdem die Stille-
   Schwelle erreicht war — listeners wurden pro Aufnahme bis zu 5x
   getriggert. Parallel laufende stopRecording()-Calls lieferten
   audio-recorder-player's nativen Layer OOM / Crash.

   Fix: silenceFired-Latch + Timer-Clear SOFORT beim ersten Feuer
   (fireSilenceOnce-Helper). Gleiche Logik fuer Max-Dauer + Conv-Window.

2. VoiceButton silence-listener re-registrierte bei jedem isRecording-
   Flip (deps [isRecording, onRecordingComplete]). Closure-State war
   stale, und bei schnellen flips gabs register/unregister-Races.

   Fix: empty deps, state direkt vom audioService via getRecordingState()
   lesen. onRecordingComplete via Ref (damit der Callback aktuell bleibt
   ohne re-register).

3. handleTap las den Button-State aus React (isRecording), der bei
   schnellen Taps stale sein konnte — "erst zweiter Tap geht" Symptom.

   Fix: audioService.getRecordingState() als Source-of-Truth, plus
   tapBusy-Ref als Anti-Doppel-Tap-Guard waehrend asyncer start/stop.
   'processing'-State wird korrekt ignoriert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:47:53 +02:00
duffyduckandClaude Opus 4.7 fd95af2c40 debug: Log wenn Pre-Roll-Fallback bei kurzem Text greift
Stefan hat aufgeklaert: Auto-Playback geht nur bei LANGEN Saetzen, bei
kurzen nicht. Das passt zur Pre-Roll-Logik: wenn weniger als pre-roll
Bytes gepuffert werden, soll eigentlich der Fallback in end() greifen,
der nach queue-Timeout play() aufruft.

Neuer Log-Eintrag zeigt ob der Fallback ausgeloest wurde:
  "Playback gestartet VOR Pre-Roll (kurzer Text, NNNNB gepuffert)"

Beim naechsten Test mit adb logcat sehen wir direkt:
  * Fallback-Log kommt → play() wurde aufgerufen, Problem liegt woanders
  * Fallback-Log kommt NICHT → endRequested wird nicht rechtzeitig
    erkannt oder Race mit concurrent handlePcmChunk-Calls

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:42:28 +02:00
duffyduckandClaude Opus 4.7 9e12e0001c debug: Logs fuer Auto-Playback-Bug — canPlay + silent-state sichtbar
Stefan berichtet dass Auto-Playback trotz Closure-Fix nicht greift.
Zwei neue Log-Zeilen die beim naechsten Test direkt zeigen was schief
laeuft:
  - ChatScreen: "[Chat] audio-msg canPlay=X (enabled=Y muted=Z)"
  - audio.ts:   "[Audio] PCM-Stream start: silent=X messageId=Y ..."

Ausreichend um zu unterscheiden:
  * canPlay=false trotz Mund-an → ttsMuted bleibt im State haengen
  * canPlay=true aber silent=true in audio.ts → Ref-Bug oder race
  * silent=false aber nichts hoerbar → native-module oder audio-routing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:38:22 +02:00
duffyduck 1d34143be5 release: bump version to 0.0.5.7 2026-04-25 00:31:12 +02:00
duffyduckandClaude Opus 4.7 0fc11e33c8 docs: NO_REPLY-Bug raus — schon durch NO_REPLY-Filter (Zeile 45) erledigt
War doppelt gelistet: einmal als erledigter Filter und einmal als
offener "wird als NO angezeigt"-Bug. Der Filter in aria-bridge verwirft
NO_REPLY-Antworten heute still, der Anzeige-Bug ist damit praktisch weg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:29:29 +02:00
duffyduckandClaude Opus 4.7 dae603541b docs: issue.md aufgeraeumt — erledigte Items in die Done-Liste
Offene Bugs auf 3 reduziert (NO_REPLY-Anzeige, Porcupine-Jarvis,
Porcupine-Crash — die zwei letzten abhaengig vom ADB-Logcat-Test).
App-Features-Backlog auf 2 (History-Race, Background-Audio) weil
Text-Auswahl/Autolink/Speed-Setting/Voice-Preview jetzt fertig sind.

Diagnostic Features-Abschnitt leer → geloescht.

Erledigt-Liste um ~18 Punkte ergaenzt (F5-TTS Pre-processing, deutsches
Fine-Tune, maxPayload-Fix, service_status, config_request, Conversation-
Window, Porcupine, HF-Cache Bind-Mount, cleanup-windows, Mute-Bug,
Zombie-Recording, Autolink, Speed-Setting, Preview-Modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:37 +02:00
duffyduckandClaude Opus 4.7 87b4cd305c docs: veraltete F5-TTS-Backlog-Items ausmisten
- Audio-Normalisierung: nie aufgefallen, bei Bedarf zurueck
- F5-TTS Streaming-Inferenz: Upstream-Feature, nicht unseres
- Deepspeed: premature optimization, Render ist durch Pre-Roll
  kaschiert schnell genug
- BigVGAN-Support: obsolet seit Vocos + aihpi German Fine-Tune laeuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:26:51 +02:00
duffyduckandClaude Opus 4.7 190352820c feat: Bug-Runde + 5 App/Diagnostic-Features
Bugs:
- App Mute-/Auto-Playback: onMessage-Closure hielt stale ttsDeviceEnabled/
  ttsMuted → Mute wurde ignoriert + AsyncStorage-Load kam nicht durch.
  Fix via ttsCanPlayRef (live gespiegelt) statt Closure-Variablen.
- App Zombie-Recording: toggleWakeWord hat die laufende Aufnahme nicht
  gestoppt → audioService.recordingState blieb 'recording' → normaler
  Aufnahme-Button wirkungslos. Fix: await stopRecording() vor stop().
- Porcupine robuster: BuiltInKeywords-Enum Mapping mit String-Fallback,
  errorCallback fuer Runtime-Crashes (state zurueck auf off statt
  App-Crash), mehr Logging damit man beim naechsten Issue debuggen kann.

App-Features:
- MessageText Komponente: Text ist durchgehend selektierbar, erkennt
  URLs (http/https), E-Mails, Telefonnummern und macht sie anklickbar
  (oeffnet Browser / Mail-App / Android-Dialer via Linking).
- TTS-Wiedergabegeschwindigkeit pro Geraet einstellbar (Settings ->
  "Sprechgeschwindigkeit", 0.5-2.0 in 0.1-Schritten, Default 1.0).
  Wird als speed-Param an die F5-TTS-Bridge durchgereicht.

Bridge-Durchreichen:
- ChatScreen: speed aus AsyncStorage via ttsSpeedRef, an chat/audio/
  tts_request mitgeschickt
- aria-bridge: _next_speed_override wie voice_override, an xtts_request
  weitergereicht
- f5tts-bridge: speed-Param an F5TTS.infer() durchgereicht

Diagnostic-Feature:
- Voice-Preview-Button (Play-Icon) vor dem Delete-X in der Stimmen-Liste
- Modal mit Textfeld (Default-Beispieltext wird bei jedem Oeffnen neu
  gesetzt) und Play-Button
- Server sammelt audio_pcm Frames der Preview-Anfrage, baut WAV,
  schickt base64 zurueck, Browser spielt im <audio>-Tag ab
- 60s Timeout-Safety-Net

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:24:02 +02:00
duffyduckandClaude Opus 4.7 2264f4e3bc fix: Leeres Feld im Diagnostic bedeutet jetzt wirklich "reset auf default"
Bug: User leert "Custom Checkpoint" in Diagnostic, klickt Anwenden, aber
die Bridge behielt den alten Wert weiter (z.B. BigVGAN-Pfad). Ursache:
  - Server loeschte den Key bei leerem String aus voice_config.json
  - Bridge's update_config sah key absent → "keep current" Semantik
  - Resultat: kein Reset, alter Pfad blieb aktiv, NaN-Output blieb

Fix auf beiden Seiten:
  - diagnostic/server.js: Keys werden immer mit dem Wert gesetzt (auch "")
    statt geloescht. "" landet jetzt explizit in der config.json.
  - f5tts/bridge.py: update_config unterscheidet jetzt:
      * key fehlt in payload  → current behalten (unveraendert)
      * key da + leer         → RESET auf DEFAULT_F5TTS_* (User-Wunsch)
      * key da + Wert         → neuen Wert nehmen

Damit kann der User in Diagnostic ein Feld leeren + Anwenden und die
Bridge schaltet wirklich auf Hard-Default zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:51:10 +02:00
duffyduckandClaude Opus 4.7 58fd8721e3 fix: Voice-Transkription erzwingt kein "small" mehr — nutzt geladenes Modell
f5tts/bridge.py: das hardcoded model="small" in request_transcription war
ein Fehler — whisper-bridge hat dadurch stumm zwischen Modellen geswappt.
Wenn User large-v3 in Diagnostic eingestellt hatte:
  - f5tts Voice-Transkribierung triggerte Swap zu "small"  (+~500MB Laden)
  - Danach aria-bridge schickte naechsten stt_request mit large-v3
    (+~3GB Laden weil small jetzt im RAM war)
Doppelter Load, unnoetiger Traffic.

Fix:
  - f5tts: kein model mehr im payload, whisper-bridge entscheidet
  - whisper: wenn kein payload.model UND bereits ein Modell geladen →
    das behalten. Nur wenn gar nichts da ist fallback auf WHISPER_MODEL
    env default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:03:45 +02:00
duffyduckandClaude Opus 4.7 4f494daffb docs: BigVGAN-Warnung deutlich — funktioniert nicht mit unserem Vocos-Setup
Die BigVGAN-Variante des aihpi F5-TTS Checkpoints ist nicht einfach ein
"optional besser" Fallback — sie ist mit dem Default-Vocos-Vocoder den die
f5-tts Library laedt inkompatibel. Output wird NaN, App bleibt stumm.

Stefan hat das probiert, App stumm, 10 Minuten Debugging. README war zu
locker formuliert ("Meist hoehere Quali") — jetzt klar als "funktioniert
AKTUELL NICHT" gekennzeichnet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:49:54 +02:00
duffyduckandClaude Opus 4.7 958c8d6fc6 fix(f5tts): NaN/Inf in Modell-Output sauber abfangen vor int16-Cast
F5-TTS generiert gelegentlich NaN/Inf samples — ohne sanitize lief der
int16-Cast in undefined behavior (RuntimeWarning + kaputter Sound in den
entsprechenden Stellen). Jetzt: nan_to_num vor clip, plus Warnung wie
viele samples betroffen waren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:35:57 +02:00
duffyduckandClaude Opus 4.7 5ba89c7191 docs: README-Abschnitt fuer deutsches F5-TTS Fine-Tune (aihpi)
Konfig-Tabelle mit den konkreten Diagnostic-Werten fuer das deutsche
Fine-Tune von aihpi/F5-TTS-German — Modell-Architektur, hf:// Pfade,
empfohlene cfg_strength / nfe_step. Plus Hinweis auf die BigVGAN-
Variante als Alternative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:34:36 +02:00
duffyduckandClaude Opus 4.7 b373f915b5 feat(f5tts): HF-URL Support fuer Custom Checkpoints (aihpi/F5-TTS-German)
_resolve_hf_path wandelt hf://user/repo/path → lokaler Download via
huggingface_hub.hf_hub_download. So kann man in Diagnostic einfach die
HF-Pfade fuer custom Modelle reinschreiben, ohne erst manuell zu
downloaden + zu mounten.

Format: hf://aihpi/F5-TTS-German/F5TTS_Base/model_365000.safetensors
        hf://aihpi/F5-TTS-German/vocab.txt

Diagnostic UI: Placeholders + Labels angepasst mit Beispiel-HF-Pfaden
und Hinweis dass fuer Fine-Tunes "F5TTS_Base" statt "F5TTS_v1_Base"
als Architektur-Name gesetzt werden muss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:16:44 +02:00
duffyduckandClaude Opus 4.7 7748834a0f fix(f5tts): Ref-WAV Preprocessing — Loudness + Silence-Trim
F5-TTS reagiert empfindlich auf leise / verrauschte / zerhackte
Referenzen — wir haben bisher nur auf 24kHz mono + 10s geclipped.
Jetzt zusaetzlich:
  - silenceremove am Anfang (bis Speech einsetzt, <-50dB)
  - silenceremove am Ende (0.5s Stille nach letzter Speech = Cutoff)
  - loudnorm -16 LUFS (EBU R128) fuer konsistente Amplitude

Damit sieht das Modell saubere, konstant laute Referenz-Audios statt
kaputter Clips mit Ausklang oder leiser Aufnahme. Besonders bei Deutsch
(wo F5TTS_v1_Base schwach ist) hilft jede Input-Konsistenz der Quali.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:07:58 +02:00
duffyduckandClaude Opus 4.7 8b52f4c92b fix(f5tts): Referenz-WAV auf 10s clippen + txt neu transkribieren
F5-TTS hat ein Hard-Limit von 12s fuer das Referenz-Audio — laengere
WAVs werden intern abgeschnitten, aber unser ref_text war das komplette
Transkript. Text und Audio wurden dadurch unaligned, Render-Qualitaet
leidet und der initial Warmup-Render dauerte 57s statt 5s.

Fix:
  - normalize_ref_wav(max_seconds=10): ffmpeg schneidet auf 10s + 24kHz
    mono, gibt was_modified zurueck damit Caller den txt invalidieren kann
  - handle_voice_upload: clippt VOR der Transkription, Whisper sieht also
    nur die 10s → txt passt garantiert zum Audio
  - _do_tts: checkt vor jedem Render die WAV-Dauer. WAVs > 10.5s werden
    geclippt, .txt geloescht → on-the-fly Neu-Transkription beim Render

Bestehende kaputte Voices (wie MAIA mit 600+ Worten txt zu einem 20s
Audio) werden beim naechsten Render automatisch gefixt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:42:33 +02:00
duffyduckandClaude Opus 4.7 dc20570f6d debug: Initial-Handshake Logs damit man sieht was passiert
Beim user kommt nach 'RVS verbunden' nichts mehr — Modell-Download
startet nicht, banner aktualisiert sich nicht. Vermutung: alter Code
laeuft noch (kein neu gebauter Container) ODER der Initial-Handshake
crashed silent (asyncio.create_task ohne await schluckt Exceptions).

- whisper + f5tts: Initial-Handshake mit logger.info Zeilen, damit
  man sieht ob er ueberhaupt ausgefuehrt wird
- f5tts: zusaetzlich exception-Catch + fehler-broadcast falls der
  Modell-Load crashed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:54:12 +02:00
duffyduckandClaude Opus 4.7 744a27cfd1 fix: HF-Cache zurueck + Banner-Bug + config_request Pattern
Vier Bugs in einem Aufwasch:

1. HF-Cache als Bind-Mount zurueck
   xtts/hf-cache:/root/.cache/huggingface fuer beide Bridges. War vorher
   raus, dadurch jedes Container-Restart = ~3GB Whisper-Download +
   ~1GB F5-TTS-Download. User dachte 5min ist einmalig — ist aber bei
   jedem Restart. Jetzt: einmal pro Maschine geladen, fertig.

2. Banner zeigte stale "ready"
   whisper-bridge sendete beim Connect nur dann Status wenn Modell schon
   geladen war. Sonst blieb der App/Diagnostic Banner auf dem alten
   "ready" State von vor dem Restart haengen — User sah "bereit" obwohl
   gerade gar nichts geladen war. Jetzt wird IMMER ein Status broadcast:
   ready oder loading.

3. config_request Pattern
   aria-bridge wusste nicht wann Gamebox-Bridges sich (re)connecten.
   Wenn die nach aria-bridge kamen, verpassten sie den Config-Broadcast
   und blieben mit Hard-Defaults stehen.
   Jetzt: whisper- und f5tts-bridge senden beim Connect ein
   config_request, aria-bridge antwortet mit der persistierten Config
   (whisperModel, xttsVoice, f5tts*-Felder).

4. RVS ALLOWED_TYPES um config_request erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:46:47 +02:00
duffyduckandClaude Opus 4.7 37c5f6c368 fix: dynamischer STT-Timeout — whisper Modell-Download nicht abkappen
aria-bridge horcht jetzt auf service_status fuer den Service 'whisper'.
Solange whisper-bridge im 'loading' steckt (Erst-Download large-v3 kann
1-2 Min dauern), gilt fuer stt_request ein Timeout von 300s statt 45s.
Sobald 'ready', zurueck auf 45s — reicht selbst fuer lange Audios.

Symptom vorher: Beim ersten Sprechen nach Container-Restart hat aria-
bridge nach 45s aufgegeben und lokal gefallback waehrend whisper-bridge
noch fleissig den Download laufen hatte. Damit wurde der Sinn der
Auslagerung kaputt gemacht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:06:04 +02:00
duffyduckandClaude Opus 4.7 a361015ff4 fix: WebSocket max_size hochgedreht — voice_upload sprengte Default 1MB
Symptom: aria-whisper-bridge bekam beim ersten internen stt_request
(via voice_upload mit WAV als base64, ~2.4MB) den Frame zu Gesicht,
default ws-max ist 1MB → mit Close-Code 1009 abgewiesen → Verbindung
tot → naechster stt_request lief in Timeout → lokales Fallback.

Fixes:
- whisper-bridge: max_size=50*1024*1024 in websockets.connect()
  (gleicher Wert wie f5tts-bridge schon hat)
- RVS-Server: maxPayload=50*1024*1024 in WebSocketServer-Optionen,
  damit der Server die Frames nicht selbst auf 1MB cappt bevor er
  sie an die Bridge weiterleitet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:54:36 +02:00
duffyduckandClaude Opus 4.7 d83b555209 fix(whisper): kein eager preload mehr — wartet auf config-Broadcast
Vorher: Container-Start lud erst 'small' (env default), dann nochmal
das in Diagnostic konfigurierte Modell (z.B. large-v3) wenn die
config-Broadcast vom aria-bridge ankam. Doppelter Download, doppelte
Wartezeit, doppelter VRAM-Peak.

Jetzt:
- Initial wird NICHTS geladen
- aria-bridge sendet die persistierte voice_config.json kurz nach
  RVS-Connect → whisper-bridge sieht den richtigen Modellnamen
- config-Handler erkennt: noch nichts geladen ODER Wechsel
  → loading-Broadcast → ensure_loaded → ready-Broadcast
- stt_request-Handler: gleicher Status-Broadcast falls Race-Condition
  (Spracheingabe in den ersten 1-2s nach Container-Start)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:50:46 +02:00
duffyduck a029267d9d release: bump version to 0.0.5.6 2026-04-24 16:25:53 +02:00
duffyduckandClaude Opus 4.7 8ba6a71a49 feat(app): service_status Banner oben in ChatScreen
App-Pendant zum Diagnostic-Banner. Wenn die Gamebox-Bridges (F5-TTS /
Whisper) ihren Lade-Status broadcasten, zeigt die App oben unter der
Verbindungs-Statusleiste ein farbiges Banner:
  Gelb  = irgendwas laedt   (NICHT wegtippbar)
  Gruen = alles bereit      (tippbar zum Schliessen)
  Rot   = Fehler

Banner aggregiert beide Services in einer Kachel. Dismiss-State wird
zurueckgesetzt sobald irgendein Service wieder in 'loading' geht
(z.B. Modell-Wechsel via Diagnostic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:24:47 +02:00
duffyduckandClaude Opus 4.7 2f625572fc feat: HF-Cache raus + service_status Banner in Diagnostic
Stefan akzeptiert die ~5min Modell-Download-Zeit nach jedem Container-
Start, dafuer keine 50GB Cache-Bloat mehr und kein Bind-Mount-Verzeichnis
zu pflegen.

- xtts/docker-compose.yml: hf-cache Bind-Mount entfernt fuer beide
  Bridges. Modelle werden im writable Container-Layer abgelegt und mit
  jedem `docker compose down` automatisch weggeraeumt.
- xtts/.gitignore: hf-cache/ Eintrag raus
- RVS ALLOWED_TYPES: service_status hinzu

Bridges broadcasten Lade-Status:
- f5tts-bridge: bei Connect 'loading' -> ensure_loaded -> 'ready'.
  Auch bei config-getriggertem Modell-Wechsel: erst 'loading' Broadcast,
  dann reload, dann 'ready'.
- whisper-bridge: gleiches Pattern. Modell wird jetzt erst nach
  RVS-Connect geladen damit der loading-Broadcast tatsaechlich rausgeht.

Diagnostic:
- server.js: service_status wird an Browser durchgereicht
- index.html: neues Banner unten rechts (fixed position) zeigt Status
  fuer beide Services. Aggregiert: Icon ist Lupe waehrend Loading,
  Check wenn alles ready, X bei Error.
- Wenn alles ready: X-Button erscheint (manuell schliessen) +
  nach 8s automatisches Fade-Out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:21:19 +02:00
duffyduckandClaude Opus 4.7 ac56916eb0 fix(android): minSdkVersion 23 -> 24 (Porcupine erfordert Android 7+)
@picovoice/porcupine-react-native deklariert minSdkVersion 24, dadurch
schlug der Manifest-Merger fehl wenn die App weiter auf 23 stand.
Android 7.0 ist eh das pragmatische Minimum (Geraete <7.0 sind <1% Markt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:08:10 +02:00
duffyduckandClaude Opus 4.7 ae08a5051c fix(deps): porcupine-react-native 3.0.6 existiert nicht — auf 3.0.5 pinnen
3.0.6 war geraten und gibt's nicht im npm Registry. Aktuelle stabile 3.x
ist 3.0.5; 4.0.0 hat Breaking Changes. Beide Picovoice-Packages auf
exakte Version gepinnt damit keine Auto-Bumps fies werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:03:35 +02:00
duffyduckandClaude Opus 4.7 d372cd638e release: bump version to 0.0.5.5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:37 +02:00
duffyduckandClaude Opus 4.7 60c5cb7e59 fix(cleanup-windows): ASCII-only damit Windows-PowerShell 5.1 parsen kann
Windows-PowerShell 5.1 liest .ps1 ohne UTF-8 BOM als Windows-1252;
em-dashes (-), Pfeile (->), Box-Zeichen, Emojis (OK/FAIL/WARN) wurden
als Mojibake interpretiert ("â€"" fuer "-") und sprengten den Parser.

Loesung: alle Sonderzeichen durch ASCII-Aequivalente ersetzt:
  - "-" / "->" statt em-dash / Pfeil
  - "===" statt Box-Linien
  - "[OK]" / "[FAIL]" / "[WARN]" statt Emojis
Funktioniert jetzt zuverlaessig auf jeder Windows-PS-Version ohne BOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:01:56 +02:00
duffyduckandClaude Opus 4.7 607a4c9ff8 add: cleanup-windows Self-Elevation + .bat-Wrapper
cleanup-windows.ps1:
  - Defensive Set-ExecutionPolicy Bypass am Anfang
  - Self-Elevation: wenn nicht als Admin gestartet, relauncht das Script
    sich selbst als Admin mit -ExecutionPolicy Bypass + Original-Args.
    User muss nur einmal UAC bestaetigen, kein extra Befehl mehr noetig.

cleanup-windows.bat:
  - Wrapper der powershell.exe mit -ExecutionPolicy Bypass aufruft.
  - Funktioniert auch wenn Windows die .ps1 direkt blockt (z.B. unsignierte
    Scripts global gesperrt).
  - Aufruf: cleanup-windows.bat stefan [-SkipPrune] [-PruneOnly]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:59:55 +02:00
duffyduckandClaude Opus 4.7 4ea16cfa8f add: cleanup-windows.ps1 — VHDX-Cleanup fuer Gamebox
Ein Script das auf der Gamebox (Windows + Docker Desktop + WSL2) alle
.vhdx Files findet und via diskpart compactet. Gibt den Speicherplatz
zurueck den man IN den Distros/Containern geloescht hat aber von der
VHDX bisher nicht freigegeben wurde.

Nutzung (PowerShell als ADMIN):
  .\cleanup-windows.ps1 stefan
  .\cleanup-windows.ps1 -User stefan -SkipPrune    # nur compacten
  .\cleanup-windows.ps1 -User stefan -PruneOnly    # nur prune

Default-Flow:
  1. docker system prune -a --volumes -f + builder prune
  2. wsl --shutdown
  3. Alle gefundenen ext4.vhdx (Docker Desktop + WSL-Distros) compacten
     via diskpart 'compact vdisk' (kein Hyper-V noetig)

Zeigt fuer jedes File "vor → nach (gespart X GB)" und am Ende eine
Gesamt-Summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:27 +02:00
duffyduckandClaude Opus 4.7 6ce9880bc0 fix: HF-Modell-Cache als Bind-Mount statt Docker Volume
Beide Bridges teilen sich jetzt einen Bind-Mount ./hf-cache:/root/.cache/
huggingface. Vorher waren das zwei getrennte Named Volumes
(f5tts-models + whisper-models), die unter Docker Desktop / Windows
in der docker-desktop-data.vhdx gelandet sind und die VHDX nie wieder
freigegeben haben — auch nach docker volume rm bleibt der belegte Platz
in der VHDX bis zum Factory Reset.

Bind-Mount loest beides:
  - Files direkt im xtts/hf-cache/ sichtbar, einfach im Explorer zu loeschen
  - Kein VHDX-Bloat mehr
  - Beide Container teilen sich den Cache (HF-Struktur identisch, keine
    Konflikte da andere Modelle)

Cleanup von vorhandenen 50GB:
  docker compose down
  docker volume rm xtts_f5tts-models xtts_whisper-models  (oder via
    Docker Desktop UI)
  Anschliessend in Docker Desktop: Settings -> Resources -> Disk image
    location -> Disk usage -> "Clean up" / Reset wenn die VHDX nicht
    schrumpft.

xtts/.gitignore: hf-cache/ + voices/ + .env

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:18 +02:00
duffyduckandClaude Opus 4.7 187ffad7ee feat: F5-TTS Tuning ueber Diagnostic statt .env
Folgt der "keine neuen Settings in .env" Regel.

f5tts/bridge.py:
  - F5TTS_MODEL/CKPT_FILE/VOCAB_FILE/CFG_STRENGTH/NFE_STEP ENV-Vars raus
  - Hard-coded Defaults im Code (DEFAULT_F5TTS_*)
  - F5Runner besitzt Live-Settings als Instance-Vars + update_config()
  - config-Broadcast triggert Modell-Reload nur wenn Modell-relevantes
    sich aendert (cfg_strength/nfe_step ohne Reload)
  - F5TTS_DEVICE bleibt ENV (Hardware-Bootstrap)

xtts/docker-compose.yml: F5TTS_* ENV-Vars rausgenommen, Kommentar
verweist auf Diagnostic-Config.

aria-bridge: nimmt f5tts*-Felder im config-Handler entgegen, persistiert
sie in voice_config.json. Beim RVS-Connect broadcastet die Bridge die
persistierte Config einmalig — damit die f5tts-bridge nach Container-
Restart automatisch die zuletzt gewaehlten Settings bekommt, ohne dass
der User in Diagnostic was klicken muss.

Diagnostic UI:
  - Neuer aufklappbarer "F5-TTS Modell-Tuning (advanced)" Bereich
  - Felder: Modell-ID, Custom-Checkpoint, Vocab, cfg_strength, nfe_step
  - voice_config beim Laden: Felder werden zurueck in die UI gesetzt
  - sendVoiceConfig schickt die neuen Felder mit
  - Server: send_voice_config persistiert die Felder, leere Strings
    werden geloescht damit die Hard-Defaults greifen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:44:58 +02:00
duffyduckandClaude Opus 4.7 467f95424e fix: F5-TTS Voice-Referenztext + Standard-Eintrag raus
Bug-Root: voice_upload schrieb "Das ist ein Referenz Audio." als Platzhalter
wenn die whisper-bridge nicht erreichbar war. F5-TTS bekam dann diesen Text
als Sprach-Anker, sah aber im WAV ganz andere Worte → verwirrtes Modell,
halluziniert in beliebiger Sprache (z.B. Spanisch).

Fixes:
- handle_voice_upload: schreibt KEINE Platzhalter-.txt mehr. Bei Failure
  bleibt die .txt weg → naechste TTS-Nutzung zieht via on-the-fly retry
  nach.
- _do_tts: Legacy-Platzhalter wird beim Render erkannt und geloescht,
  Transkription on-the-fly neu angezogen. Bestehende kaputte voices
  reparieren sich automatisch beim ersten Render.

UI-Aufraeumung: F5-TTS hat keine "Standard"-Stimme — der Eintrag ist raus
in App SettingsScreen + Diagnostic. Diagnostic-Dropdown hat jetzt einen
disabled-Hinweis "(keine Stimme gewaehlt)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:33:53 +02:00
duffyduckandClaude Opus 4.7 c1a5518fb7 fix(f5tts): cfg_strength hochgezogen damit Deutsch nicht ins Spanische rutscht
F5TTS_v1_Base ist hauptsaechlich auf Englisch+Chinesisch trainiert; bei
Deutsch (oder anderen Romance/Germanic-Sprachen) schwimmt der Generator
ohne starkes Conditioning gerne in eine andere Sprache.

- cfg_strength 2.0 → 2.5 (per ENV F5TTS_CFG_STRENGTH ueberschreibbar)
- nfe_step bleibt 32 (per ENV ueberschreibbar)
- F5TTS_CKPT_FILE / F5TTS_VOCAB_FILE als ENV — damit man eine Community-
  German-Checkpoint einhaengen kann ohne Code-Aenderung

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:30:08 +02:00
duffyduckandClaude Opus 4.7 22fa4b3ccf feat: Porcupine Wake-Word Integration (Built-In Keywords, "Jarvis" default)
WakeWordService wrappt jetzt Picovoice Porcupine:
  - loadFromStorage(): Access Key + Keyword aus AsyncStorage, init Porcupine
  - configure(key, keyword): Settings-Wechsel, Re-Init
  - start(): wenn Porcupine bereit → 'armed' (passives Lauschen),
    sonst Fallback auf direktes 'conversing' (klassischer Modus)
  - onWakeDetected: Porcupine pausieren → 'conversing' → wakeCallback
  - endConversation: Porcupine wieder starten → 'armed' (Wake-Word weiter
    aktiv im Hintergrund, kein erneuter Tap noetig)
  - Pro Geraet eigene Wahl: jeder User kann sein eigenes Wake-Word haben

Settings: neuer Bereich "Wake-Word"
  - Picovoice Access Key Input (mit Eye-Toggle), kostenlos auf
    console.picovoice.ai
  - Built-In Keyword Chips: jarvis, computer, picovoice, porcupine,
    bumblebee, terminator, alexa, hey google, ok google, hey siri
  - "Speichern + Aktivieren" Button mit Status-Feedback
  - Hinweis dass "ARIA" Custom-Keyword spaeter via Diagnostic kommt

ChatScreen: ruft wakeWordService.loadFromStorage() beim Mount.

package.json: @picovoice/porcupine-react-native + react-native-voice-processor
hinzugefuegt — npm install + native rebuild noetig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:23:51 +02:00
duffyduckandClaude Opus 4.7 1b8a51aad0 feat: Conversation-Window — Gespraech endet nach Stille statt Endlos-Loop
Der Gespraechsmodus war bisher ein Endless-Loop: Mikro hat sich nach
jeder ARIA-Antwort wieder geoeffnet bis MAX_RECORDING_MS, danach Speech-
Gate verworfen und neu starten. Das Ohr blieb ewig an.

Neue Logik:
  audio.ts: startRecording(autoStop, noSpeechTimeoutMs?) — wenn der User
    innerhalb des Timeouts nicht anfaengt zu sprechen, wird Stille
    gemeldet → stopRecording → Speech-Gate verwirft → result=null.
  wakeword.ts: drei States off/armed/conversing. start() geht direkt in
    'conversing' (kein Wake-Word verfuegbar; Stub fuer spaetere Porcupine-
    Integration). endConversation() bei No-Speech.
  ChatScreen: Aufnahme bekommt das Window aus AsyncStorage durchgereicht.
    Bei null-Result → endConversation, UI-State synchron.
  Settings: neuer +/- Block "Konversations-Fenster" 3-20s (Default 8).

Mit dem Stub ist die Architektur bereit fuer Porcupine: dann geht
endConversation auf 'armed' statt 'off' und der Wake-Word-Detector
laeuft passiv weiter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:14:01 +02:00
duffyduckandClaude Opus 4.7 578ade3544 docs: README + issue.md auf Stand mit F5-TTS, Whisper-Gamebox, App-Settings
README:
- Architektur-Diagramm: Gamebox-Stack mit f5tts-bridge + whisper-bridge
- Voice Bridge: STT primaer remote (Gamebox), TTS via F5-TTS
- Diagnostic-Section: Voice-Status, Disk-Voll Banner, Auto-Transkription
- App-Features: VAD-Toleranz/Pre-Roll/Audio-Pause konfigurierbar
- XTTS-Section ersetzt durch "Gamebox-Stack — F5-TTS + Whisper"
- Roadmap Phase 1: alle juengsten Erledigungen ergaenzt

issue.md: alle erledigten Punkte der letzten Iterationen aufgenommen
(Pre-Roll, Decimal-TTS, voice_ready, Whisper-Gamebox, F5-TTS, AudioFocus
Pause, VAD-Setting, ...). Offene Liste auf den aktuellen Stand reduziert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:51:27 +02:00
duffyduck ed2f1bb5ee release: bump version to 0.0.5.4 2026-04-24 14:45:17 +02:00
duffyduckandClaude Opus 4.7 0a04972455 feat: Stille-Toleranz fuer Aufnahme einstellbar in App-Settings
Neuer +/- Block in SettingsScreen → Spracheingabe → "Stille-Toleranz",
1.0-8.0s, Default 2.8s. Wert in AsyncStorage (aria_vad_silence_sec).
audio.ts liest den Wert beim Aufnahme-Start und nutzt ihn fuer den
VAD-Auto-Stop-Schwellwert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:44:17 +02:00
duffyduck 2a4379eb64 release: bump version to 0.0.5.3 2026-04-24 14:41:59 +02:00
duffyduckandClaude Opus 4.7 e64df23bb7 fix: TTS pausiert andere Apps statt zu ducken + VAD/Mic laenger
AudioFocus.requestDuck nutzt jetzt AUDIOFOCUS_GAIN_TRANSIENT (statt
TRANSIENT_MAY_DUCK) — Spotify/YouTube pausieren komplett solange ARIA
spricht und kommen nicht mitten drin wieder hoch.

PcmStreamPlayer.end() resolved jetzt erst wenn der native Writer-Thread
wirklich fertig ist (alle Samples aus dem Pre-Roll-Puffer ausgespielt).
audio.ts wartet entsprechend, bevor AudioFocus.release() gerufen wird —
behebt das "Musik dreht hoch waehrend Antwort noch laeuft"-Problem.

Mic-Aufnahme: VAD_SILENCE_DURATION_MS 1800 → 2800ms (mehr Toleranz fuer
Sprechpausen), MAX_RECORDING_MS 30s → 120s (laengere Erklaerungen
moeglich, Notbremse bleibt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:40:58 +02:00
duffyduckandClaude Opus 4.7 576ae925dd feat(phase2): XTTS durch F5-TTS ersetzt — Voice Cloning auf der Gamebox
Neuer aria-f5tts-bridge Container:
  - Python-Service, laedt F5TTS_v1_Base beim Start
  - Empfaengt xtts_request via RVS, synthetisiert mit Voice-Cloning,
    streamt PCM-Chunks (audio_pcm, 16-bit s16le) wie zuvor die XTTS-Bridge
  - Teilt lange Texte an Satzgrenzen, streamt satzweise
  - Fade-In auf erstem Chunk, Queue gegen parallel-Render

Voice-Management:
  - Speicherort weiterhin /voices/, aber jetzt als Paar
    {name}.wav + {name}.txt (F5-TTS braucht Referenz-Transkription)
  - voice_upload: WAV speichern, intern stt_request an whisper-bridge
    senden, Transkription als .txt ablegen → user muss nichts eintippen
  - On-the-fly Transkribierung: wenn eine WAV ohne .txt liegt, wird
    bei erstem Render/Preload nachgezogen
  - Bestehende RVS-Messages (voice_upload/xtts_list_voices/... etc.)
    bleiben unveraendert → keine App/Diagnostic-Aenderung noetig

Gaming-PC docker-compose:
  - xtts + xtts-bridge Services entfernt
  - f5tts-bridge + whisper-bridge bleiben/kommen rein
  - Volume xtts-models → f5tts-models

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:34:11 +02:00
duffyduckandClaude Opus 4.7 e170991222 fix: _send_to_rvs gibt Success-Bool zurueck, _stt_remote bricht bei
Send-Fehler sofort ab statt in den 45s-Timeout zu laufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:16:08 +02:00
duffyduckandClaude Opus 4.7 a1343ee18f debug: Logs beim stt_request-Roundtrip — aria-bridge loggt beim Senden,
whisper-bridge loggt eingehende stt_request (id + Audio-Groesse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:13:41 +02:00
duffyduckandClaude Opus 4.7 b2d3c935d8 fix(whisper): requests explizit als Dependency — faster-whisper 1.0.3
zieht sie selber nicht rein, Container crashed sonst beim Import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:59:59 +02:00
duffyduck 49089eee4b release: bump version to 0.0.5.2 2026-04-24 13:50:19 +02:00
duffyduckandClaude Opus 4.7 e544992c9f feat(phase1): Whisper STT auf die Gamebox ausgelagert
Neuer Container aria-whisper-bridge auf der Gamebox — faster-whisper
CUDA mit float16. Der Container verbindet sich per WebSocket an den RVS,
nimmt stt_request entgegen, laeuft ffmpeg+Whisper, antwortet mit
stt_response. Hoert zusaetzlich auf config-Broadcasts und lädt das
Modell hot-swap bei Diagnostic-Wechsel.

aria-bridge ruft jetzt primaer die Gamebox an; nur wenn die nicht binnen
45s antwortet, faellt auf lokales Whisper (CPU) zurueck. Das lokale
Modell wird lazy geladen, spart RAM auf der VM.

RVS: stt_request/stt_response zur ALLOWED_TYPES-Liste.

Diagnostic-Voice-Config (whisperModel-Feld) bleibt unveraendert —
die Auswahl wird an die Gamebox durchgereicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:42:07 +02:00
duffyduck 97a1a3089a release: bump version to 0.0.5.1 2026-04-23 22:02:17 +02:00
duffyduck 64f18e97a0 release: bump version to 0.0.5.0 2026-04-23 15:31:18 +02:00
duffyduckandClaude Opus 4.7 9cbea27455 feat: voice_preload/voice_ready — Feedback wenn neue Stimme geladen ist
XTTS-Bridge:
  - empfaengt neuen voice_preload Type, rendert stumm "ja." fuer die Stimme
    via TTS-Queue (damit kein Konflikt mit echtem TTS)
  - horcht zusaetzlich auf config-Broadcasts: wenn Diagnostic global die
    Stimme wechselt, wird auto-preloaded
  - broadcastet voice_ready mit Dauer (loadMs) oder error

RVS: voice_preload + voice_ready zur ALLOWED_TYPES-Liste.

App (SettingsScreen): beim Wechsel senden wir voice_preload, zeigen einen
Spinner in der Voice-Row und einen Toast mit "Stimme X bereit (Ns)".
App (ChatScreen): Toast auch hier — falls User gerade nicht in Settings ist.

Diagnostic (server+UI): voice_ready wird an Browser durchgereicht, ein
Status-Text unter dem Voice-Dropdown zeigt "wird geladen" → "bereit".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:24:08 +02:00
duffyduck c8881f9e4d release: bump version to 0.0.4.9 2026-04-22 23:02:28 +02:00
duffyduckandClaude Opus 4.7 028e3b2240 fix: Voice-Auswahl funktioniert endlich + Diagnostic setzt alle Apps zurueck
XTTS-Bridge: im daswer123 local-Mode erwartet der Server speaker_wav als
Basename (z.B. "Maia"), nicht als Pfad. Wir haben bisher "/voices/Maia.wav"
geschickt, was der Server stumm verwirft und Default nimmt. Jetzt: speaker
name pur senden + Warnlog wenn File fehlt.

App: ChatScreen + SettingsScreen horchen auf type "config" vom RVS —
wenn in Diagnostic die globale XTTS-Voice gewechselt wird, werden alle
Apps auf den neuen Wert zurueckgesetzt (wie vom User gewuenscht).
Lokale App-Wahl bleibt sonst intakt und gewinnt pro Request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:40 +02:00
duffyduckandClaude Opus 4.7 c042f27106 feat: generisches Buchstabieren fuer unbekannte Akronyme
Nach der expliziten _UNIT_WORDS-Liste greift eine Fallback-Regel:
alle verbleibenden 2-5-Zeichen-Grossbuchstaben-Woerter werden
buchstabiert. XTTS → X T T S, USB → U S B, DNS → D N S, JSON → J S O N.

Spezielle Faelle (WLAN, NATO — als Wort gesprochen) koennen bei
Bedarf in _UNIT_WORDS explizit ueberschrieben werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:04 +02:00
duffyduck 4ceadf8be5 release: bump version to 0.0.4.8 2026-04-22 19:08:00 +02:00
duffyduckandClaude Opus 4.7 ddd30b3059 feat: Pre-Roll-Buffer fuer TTS einstellbar in App-Settings
- Kotlin start() nimmt jetzt prerollSeconds als dritten Parameter
  (1.0-6.0s geclampt, Fallback 3.5s bei ungueltigem Wert)
- audio.ts liest Wert aus AsyncStorage vor jedem Stream-Start,
  exportiert Default/Min/Max/Key als Konstanten
- SettingsScreen: +/- Buttons direkt unter dem TTS-Toggle,
  Default auf 3.5s (von 2.5s) angehoben

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:06:55 +02:00
duffyduckandClaude Opus 4.7 6c8ba5fe2d fix: Fade-In auf ersten PCM-Chunk — maskiert XTTS-Warmup-Glitches
XTTS daswer123 hat am Anfang jedes Renders Warmup-Artefakte — die
ersten autoregressiv generierten Tokens haben wenig Kontext und klingen
verzerrt. Ein 120ms Linear-Fade-In auf den ersten ausgehenden PCM-Chunk
blendet das sanft auf und versteckt die Glitches, ohne dass das echte
Audio danach leiser klingt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:01:36 +02:00
duffyduckandClaude Opus 4.7 32ddac002f fix: stream_chunk_size auf 250 erhoeht — weniger Render-Artefakte
XTTS daswer123 erzeugt an Chunk-Grenzen oft Glitches in den Worten
die ueber die Grenze gehen. 100 → 250 = weniger Grenzen pro Satz =
sauberere Sprachausgabe. Erste-Audio-Latenz steigt um ein paar Sekunden,
was aber OK ist seit die App Pre-Roll gepuffert ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:56:00 +02:00
duffyduck bbbe69d928 release: bump version to 0.0.4.7 2026-04-22 18:46:25 +02:00
duffyduckandClaude Opus 4.7 23c39d5bba feat: Dezimalzahlen fuer TTS ausschreiben + Leading-Silence im Stream
- aria_bridge clean_text_for_tts: "0.1" / "0,5" / "1,25" wird jetzt als
  "null komma eins" / "null komma fuenf" / "eins komma zwei fuenf"
  ausgeschrieben. Lookahead verhindert Match auf IP-artige Strings.
- PcmStreamPlayer: 200ms Stille am Stream-Anfang, damit AudioTrack
  sauber anfaehrt und die ersten Worte nicht verschluckt werden.
  (XTTS-Warmup + play()-Startup-Latenz)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:44:38 +02:00
duffyduck 5328dc8595 release: bump version to 0.0.4.6 2026-04-22 18:32:31 +02:00
duffyduckandClaude Opus 4.7 0c03b4f161 fix: Stream-Ende wartet auf playbackHeadPosition vor release()
AudioTrack.stop() + release() direkt nach dem letzten write() killt die
letzten Sekunden Audio — die Samples sind zwar im Buffer, aber noch
nicht durch die Hardware rausgespielt. Deshalb brach die Sprachausgabe
mitten im Satz ab (z.B. bei "diesmal").

Fix: Writer-Thread wartet im finally-Block bis playbackHeadPosition die
Anzahl geschriebener Frames erreicht, dann erst stop()/release().
Safety: 2s Stall-Detection, falls AudioTrack haengen bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:31:12 +02:00
duffyduck 31fe70bab5 release: bump version to 0.0.4.5 2026-04-22 18:18:20 +02:00
duffyduckandClaude Opus 4.7 39251b3d32 feat: AudioTrack Pre-Roll — Playback startet erst nach 2.5s Vorrat
User-Diagnose: Erneutes Abspielen aus Cache funktioniert komplett, aber
Live-Stream bricht ab. Bedeutet: PCM kommt an, Cache ist okay — Problem
ist Buffer-Underrun im AudioTrack wenn XTTS (RTF 1.48 auf RTX 3060)
langsamer rendert als Echtzeit-Playback konsumiert.

Fix: AudioTrack.play() wird NICHT mehr sofort beim start() aufgerufen.
Stattdessen:
- start() baut AudioTrack, Writer-Thread startet, spielt aber noch nicht
- writeChunk() fuellt queue, Writer schreibt in AudioTrack-internen Buffer
  (blocked wenn der voll ist)
- Sobald bytesBuffered >= 2.5s Audio im Buffer: play() aufrufen
- Falls end() kommt bevor Pre-Roll erreicht (kurze Texte): trotzdem play()

Das gibt dem Stream Zeit Vorrat aufzubauen. XTTS kann dann pausieren
zwischen Text-Chunks ohne dass Playback stottert.

Pre-Roll 2.5s reicht fuer typische Render-Pausen zwischen Chunks.
Buffer groesse = 2x Pre-Roll damit wir auch extrem bursty Delivery
puffern koennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:16:02 +02:00
duffyduckandClaude Opus 4.7 0623de32a0 tune: stream_chunk_size 200 -> 100 gegen 6s Initial-Latenz
Mit RTF 1.48 (RTX 3060) rechnet XTTS fuer 200 chars ca. 6s bis erster
PCM-Chunk rauskommt — User wartet nach ARIA-Antwort 6s auf Sprachausgabe.

stream_chunk_size=100: Erster Chunk in ~3s bereit, reduziert
Initial-Latenz um ~50%. 100 chars sind auch noch gross genug dass
der AudioTrack-Buffer (128KB ≈ 2.7s Audio) zwischen Render-Chunks
nicht leerlaeuft → kein mid-sentence Abbruch wie bei 40.

Falls bei bestimmten Texten doch Gaps: stream_chunk_size zurueck auf
150, oder pre-roll im Android PcmStreamPlayer einbauen (nur starten
wenn X ms gepuffert sind).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:08:10 +02:00
duffyduckandClaude Opus 4.7 cd5e6e7ee6 fix: stream_chunk_size 40 -> 200 gegen Audio-Abbrueche mid-sentence
Bei stream_chunk_size=40 teilte XTTS Text in ~40-char Batches.
Zwischen Batches pausiert XTTS (RTF 1.48 auf RTX 3060 → langsamer
als Realtime-Wiedergabe). AudioTrack-Buffer lief leer, Track
stoppte, nachkommender PCM kam zu spaet → Audio bricht mid-sentence
ab (User-Bug: bei 73-char Text Abbruch nach Wort 'diesmal' was genau
an der 40-char Grenze lag).

stream_chunk_size=200:
- Kurze Saetze (<200 chars) komplett in einem Render → kein Abbruch
- Laengere Texte: groessere Chunks, laenger Audio pro Chunk als
  Render-Pause → Buffer bleibt gefuellt
- Kompromiss: first-audio-latency etwas hoeher, aber keine Abbrueche

Wenn spaeter Audio-Abbrueche bei langen Texten: stream_chunk_size
noch groesser setzen ODER einen "pre-roll" Buffer in der App.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:06:25 +02:00
duffyduckandClaude Opus 4.7 ee3e0a0af6 fix: XTTS local-Mode per ENV statt command-Override
Das Image-Default-CMD liest Konfig aus ENV Variablen:
  CMD: ... -ms \${MODEL_SOURCE:-"apiManual"}

Also reicht MODEL_SOURCE=local — command bleibt Image-Default und wir
sparen uns den brueckigen Override der schief ging (python nicht da,
flag-Namen raten, etc.).

Zusaetzlich: EXAMPLE_FOLDER=/voices damit der Speaker-Folder auf unser
gemountetes /voices zeigt (sonst /app/example was nur die Demo-Voices
enthaelt).

Kein command override mehr noetig — das Image macht alles wie vorher,
nur mit local-Mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:59:39 +02:00
duffyduckandClaude Opus 4.7 0783b1b99d fix: XTTS command nutzt python3 statt python
Image hat nur /usr/bin/python3, kein 'python'-Symlink.
Vorher ging's weil kein command override — das Image-Default CMD
lief durch. Wir ueberschreiben nur damit wir -ms local setzen koennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:58:29 +02:00
duffyduckandClaude Opus 4.7 5492c7a46f fix: XTTS command braucht 'python -m xtts_api_server' als erstes Arg
NVIDIA-Entrypoint fuehrt 'exec \$@' aus — erstes Arg muss ein
ausfuehrbares sein. Nur Flags zu geben ('--listen') fuehrt zu
'exec: --: invalid option'.

Fix: command=['python','-m','xtts_api_server','-ms','local',...]
Damit wird der xtts_api_server Python-Modul gestartet und im
local-Mode konfiguriert.

Ob die Flag-Namen exakt stimmen (-hs/-p/-ms/-o/-mf/-sf) — falls
nicht, poppt ein klarer Python-Fehler im Log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:49:29 +02:00
duffyduckandClaude Opus 4.7 4cbe184faa feat: XTTS auf local-Mode (dauerhaft im VRAM) + /tts_stream + Fallback
Root cause der langen Render-Zeiten und /tts_stream 400-Errors:
daswer123 default ist apiManual/api-Mode — Modell wird pro Request
gefetched/reloaded, Streaming unsupported.

Fix in xtts/docker-compose.yml:
  command: ['--listen', '-p', '8020', '-t', 'http://0.0.0.0:8020',
            '-ms', 'local',
            '-o', '/app/output', '-mf', '/app/xtts_models', '-sf', '/voices']

-ms local:
  - Modell dauerhaft im GPU-VRAM (~2GB, passt auf RTX 3060 mit 12GB)
  - Render startet sofort, kein per-Request-Load mehr
  - /tts_stream unterstuetzt → echtes progressive streaming
  - time-to-first-audio ~500ms statt 8-11s

xtts/bridge.js:
  /tts_stream primary, /tts_to_audio/ als Fallback wenn Stream fehlt.
  Robust: wenn User spaeter den Mode wieder umstellt, fallback greift.

Erste Ladung nach dem Wechsel dauert einmalig laenger (Modell ins VRAM
laden). Danach: schnell + streaming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:38:53 +02:00
duffyduckandClaude Opus 4.7 647a1cb726 fix: XTTS nutzt direkt /tts_to_audio/ — /tts_stream nicht verfuegbar
XTTS-Server (daswer123) im API-Modus antwortet auf /tts_stream mit:
  HTTP 400: "HTTP Streaming is only supported for local models"

Das Feature braucht MODE=local in der XTTS-Config (Modell direkt im
Server-Prozess). Userbetreibt im Remote-Modus → kein Streaming.

Der try /tts_stream + fallback /tts_to_audio Ansatz war reine Ver-
schwendung: jeder Request wartete 6ms auf 400, bevor der Fallback
griff. Jetzt geht's direkt an /tts_to_audio/.

Kein echtes Streaming, aber:
- Queue sorgt fuer sequentielle Verarbeitung (kein Overlap mehr)
- 32x AudioTrack-Buffer faengt den bursty Response ab
- aria-bridge spiegelt audio_pcm nicht mehr (kein Doppel-Audio)

Wenn User spaeter /tts_stream haben will:
  XTTS-Server mit MODE=local oder --streaming-mode starten,
  dann kann man /tts_stream als primary einfuehren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:23:55 +02:00
duffyduckandClaude Opus 4.7 73263b69a6 fix: /tts_stream — speaker_wav muss IMMER als query-param gesetzt sein
XTTS-Server (daswer123) markiert speaker_wav als required Pydantic-Feld.
Mein 'if (speakerWav) qs.set(...)' hat den Key bei default-voice
weggelassen → HTTP 422 'Field required, input: null' → Fallback auf
/tts_to_audio/ hat gegriffen, aber Streaming nie gefunden.

Log-Beweis vom User:
  XTTS /tts_stream 422: {"detail":[{"type":"missing","loc":["query",
    "speaker_wav"],"msg":"Field required","input":null}]}

Fix: Key immer setzen, leerer String bei default-voice. POST-Variante
(/tts_to_audio/ JSON-Body) hat das auch so akzeptiert — GET-Query nun
gleiches Verhalten.

Ab jetzt sollte /tts_stream endlich greifen und echte Streaming-Latenz
(~300-500ms) zeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:47:28 +02:00
duffyduckandClaude Opus 4.7 c62ceafdc2 fix: XTTS-Endpoint mit Fallback-Chain + Diagnose-Logs
Problem: /tts_stream hat bei User nicht funktioniert → keine
Sprachausgabe mehr. Server hatte vorher 405 fuer POST geantwortet,
meine Umstellung auf GET scheint aber einen anderen Fehler zu
produzieren der nicht geloggt wurde.

Fix:
- streamXTTSAsPCM() = /tts_stream (GET, Streaming) mit ausfuehrlichem
  Error-Logging bei non-200 Response
- streamXTTSBatch() = /tts_to_audio/ (POST, Batch) als Fallback
- handleTTSRequest versucht Stream zuerst, bei Exception Fallback
  auf Batch — so gibt's IMMER Audio, auch wenn /tts_stream kaputt ist
- Log zeigt welcher Pfad benutzt wurde

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:53:10 +02:00
duffyduckandClaude Opus 4.7 9b5a35cb4a fix: /tts_stream als GET mit Query-Params (war 405 Method Not Allowed)
daswer123 xtts-api-server hat /tts_stream nur als GET:
  allow: GET → POST gab 405 → Request hing.

Umstellung:
- method: 'GET'
- text/language/speaker_wav/stream_chunk_size als URLSearchParams
  im Query-String
- kein body mehr (kein req.write, kein Content-Length)

Ab jetzt echter streaming-Flow: Samples kommen waehrend XTTS noch
rendert, time-to-first-audio ~300-500ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:52:16 +02:00
duffyduckandClaude Opus 4.7 5ac1a0a522 revert: XTTS-Endpoint zurueck auf /tts_to_audio/
/tts_stream war bei der aktiven daswer123-Version nicht erreichbar —
Requests hingen stille, App bekam kein Audio.

Zurueck auf /tts_to_audio/ + Queue + 32x AudioTrack-Buffer. Das ist
zwar nicht echt-streaming aber stabil. Ueberlappung sollte durch die
Queue weg sein, Buffer toleriert den bursty Delivery.

Echt-Streaming-Migration spaeter mit verifizierter Server-Version
oder anderem Endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:48:27 +02:00
duffyduck a28b46a809 release: bump version to 0.0.4.4 2026-04-20 16:42:19 +02:00
duffyduckandClaude Opus 4.7 59c8d36a3d fix: Streaming TTS nutzt jetzt echt den /tts_stream Endpoint von XTTS
Vorher: /tts_to_audio/ — XTTS rendert kompletten WAV BEVOR es
antwortet. Mein "streaming" war nur fake-chunking des fertigen WAV.
Time-to-first-audio = komplette Render-Zeit (2-4s), dann Burst,
dann Stille. Plus bei langen Antworten: Queue blockiert.

Jetzt: /tts_stream — daswer123's chunked-transfer endpoint.
Samples flutschen waehrend der Generierung durch die Response raus.

Parameter:
- stream_chunk_size=40 → XTTS rendert in ~40-char Haeppchen intern,
  time-to-first-audio ~300-500ms statt 2-4s
- WAV-Header kommt wie gewohnt am Anfang (44 Bytes), danach raw PCM
  → mein existierender Header-Parser + 8KB-Chunker passen weiter

Voraussetzung: daswer123/xtts-api-server hat diesen Endpoint (ab
Version ~0.8.x). Sollte bei der aktuellen Version drin sein.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:35:55 +02:00
duffyduck 79ba7b8487 release: bump version to 0.0.4.3 2026-04-20 08:01:46 +02:00
duffyduckandClaude Opus 4.7 ba62cec78c fix: Disk-Banner — Safe-Cleanup als Default + Aggressiv hinter Expander
Safe-Variante (Default):
  docker builder prune -a -f && docker image prune -a -f
  → Build-Cache + ungenutzte Images, KEINE Volumes angefasst.
  → 90% des Platzproblems geloest, Null Datenverlust-Risiko.

Aggressive Variante (nur auf Wunsch, hinter 'Mehr'-Button):
  docker system prune -a --volumes -f
  → Zusaetzlich ungenutzte Volumes.
  → Nur sicher wenn alle ARIA-Container LAUFEN (sonst werden
     openclaw-config/claude-config/aria-shared als "ungenutzt"
     behandelt und zerstoert — Sessions weg).
  → Hinweistext orange hervorgehoben mit Warnung.

Banner-Button 'Sicher aufraeumen' kopiert die sichere Variante.
'Mehr' klappt die Erklaerung der aggressiven Variante aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:39:22 +02:00
duffyduckandClaude Opus 4.7 f15b3f583f feat: Disk-Space Banner im Diagnostic mit Cleanup-Command zum Kopieren
Server:
- checkDiskSpace() prueft alle 30s 'df -B1 /shared' (zeigt Host-Disk
  da /shared ein Volume auf dem Docker-FS ist)
- 4 Stufen: ok (<70%), info (70%), warn (85%), critical (95%)
- Broadcastet disk_status nur bei Aenderung (Level oder Prozent)
- currentDiskStatus wird gecached → neu verbundene Clients bekommen
  den aktuellen Stand sofort beim 'init'

UI:
- Sticky Banner ganz oben, versteckt wenn Disk ok
- Farbe nach Level: gelb (info), orange (warn), rot (critical)
- Zeigt Prozent, Used/Total/Avail in GB, konkrete Situation
- Cleanup-Command als monospace Code mit Copy-Button ('docker system
  prune -a --volumes -f') — Click auf Code oder Button kopiert ins
  Clipboard, Fallback auf Range-Selektion
- 'Schliessen' Button fuer temporaeres Ausblenden (kommt aber wieder
  bei naechster Aenderung)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:36:36 +02:00
duffyduckandClaude Opus 4.7 402bddc18a fix: Streaming TTS — Queue in XTTS-Bridge + groesserer Android-Buffer
1) Ueberlappende Streams
   Wenn zwei xtts_requests schnell hintereinander kamen, rannten
   sie parallel durch handleTTSRequest. Beide HTTP-Requests an XTTS
   liefen gleichzeitig, beide streamen PCM an App → Chunks aus BEIDEN
   Renders landeten interleaved in der AudioTrack-Queue → Chaos.

   Fix: ttsQueue als Promise-Chain — handleTTSRequest() haengt sich
   ans Ende der Kette an. Requests werden sequenziell abgearbeitet.

2) AudioTrack-Buffer zu klein fuer bursty Delivery
   XTTS /tts_to_audio/ ist NICHT echt streaming — der Server rendert
   intern den kompletten WAV und schickt ihn dann burst-weise. Der
   alte 8x-MinBuffer (ca 200-400ms) war zu klein um das abzufangen.

   Fix: Buffer auf 32x MinSize / mind. 128KB = ca. 2.7s bei 24kHz.
   Das toleriert typische XTTS-Render-Latenz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:27:16 +02:00
duffyduckandClaude Opus 4.7 350069d371 fix: Streaming TTS — doppeltes Audio + Gaps zwischen Saetzen
Zwei Probleme gefunden:

1) DOPPELTES AUDIO (Kern-Ursache der Artefakte)
   aria-bridge hat audio_pcm von XTTS-Bridge empfangen und per
   _send_to_rvs rebroadcastet. RVS broadcast geht an ALLE Clients
   ausser Sender — die App bekam jeden Chunk also zwei mal:
     XTTS-Bridge → RVS → App + aria-bridge
     aria-bridge → RVS → App (nochmal!) + XTTS-Bridge
   Zwei ueberlagerte PCM-Streams klingen wie Doubled/Artefakte.
   Fix: aria-bridge ignoriert audio_pcm jetzt. messageId schickt
   XTTS-Bridge selbst im Payload (via xtts_request -> messageId).

2) GAPS ZWISCHEN SAETZEN (abgehackt)
   xtts/bridge.js teilte Text in ~150-char Chunks und rief pro Chunk
   einen eigenen /tts_to_audio/ Request. Zwischen Chunks lag die
   XTTS-Render-Zeit (1-3s) → hoerbare Pausen.
   Fix: cleanText geht JETZT in einem Request komplett an XTTS.
   Ein zusammenhaengender Stream → keine Satz-Gaps mehr.
   Kompromiss: Erste Samples kommen spaeter (ganze Text-Render dauert
   laenger als der erste Satz alleine), aber dann kontinuierlich
   ohne Unterbrechung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:15:57 +02:00
duffyduck 019c078393 release: bump version to 0.0.4.2 2026-04-19 23:01:55 +02:00
duffyduckandClaude Opus 4.7 d411df4074 feat: Mode-Wechsel auch aus Diagnostic global syncronisiert
Vorher: Diagnostic's setMode sendete einen faked chat mit der
Aktivierungsphrase ('ARIA, Hangar-Modus') — das wurde erst in
_process_core_response auf dem ARIA-Antwort-Text detected, war
unzuverlaessig und nutzte nicht den sauberen mode-Message-Path.

Nachher: sauberer set_mode-Pfad mit Live-Sync.

diagnostic/server.js:
- Neue action 'set_mode' → sendet type=mode an RVS direkt
- RVS-Message-Handler: type=mode Broadcast von Bridge wird an
  Browser-Clients durchgereicht

diagnostic/index.html:
- setMode() nutzt jetzt action=set_mode (keine Phrase mehr)
- updateModeUI separat — wird bei Broadcast auch aufgerufen
- Mode-Broadcast vom Server syncs UI live (andere Diagnostic/App
  hat gewechselt → unser UI aktualisiert sofort)
- Button data-mode + MODE_LABELS auf kanonische IDs umgestellt
  (nicht_stoeren, fluester statt dnd, whisper)

bridge/modes.py:
- canonical_id() liefert die IDs die App + Diagnostic kennen
  (nicht_stoeren, fluester, ...) — damit Broadcast-ID zur UI-ID passt

bridge/aria_bridge.py:
- _broadcast_current_mode nutzt canonical_id statt enum.name.lower()

Flow jetzt:
  Diagnostic wechselt Mode → set_mode → Bridge → persist + broadcast
  → alle Apps + alle Diagnostic-Browser-Tabs aktualisieren sofort

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:57:41 +02:00
duffyduckandClaude Opus 4.7 763e0d79ab feat: Betriebsmodus global + persistent + Live-Sync
Vorher:
- Modus war nur in-memory in der Bridge, Restart = zurueck auf NORMAL
- App-Wechsel wurde zwar empfangen, aber nicht an andere Geraete
  gebroadcastet (nur Bestaetigung an den Sender)
- Neue App-Verbindung wusste nicht welcher Modus gerade aktiv ist

Jetzt:
- Persistiert in /shared/config/mode.json beim Wechsel
- Beim Bridge-Start: _load_persisted_mode() holt letzten aktiven Modus
- _broadcast_current_mode() sendet an ALLE Clients (Broadcast) —
  jedes verbundene Geraet bekommt live den Wechsel mit
- Bei RVS-Reconnect: sofortiger Broadcast damit neu verbundene Apps/
  Diagnostic ihre UI syncen koennen
- Loop-Schutz: payload.sender=="bridge" wird im mode-Handler ignoriert
  (sonst echo → Broadcast-Storm bei verbundenem RVS)

Beispiel-Flow:
  Geraet A aktiviert 'Hangar'
  → Bridge empfaengt mode-msg
  → persist in mode.json
  → broadcast an alle Clients (mit sender="bridge")
  → Geraet B/C/Diagnostic empfangen → UI updated sofort
  → Bridge-Restart spaeter: HANGAR wird wieder geladen

Anmerkung zu echten OS-Push bei geschlossener App:
Das braucht FCM/Firebase + BackgroundService — deutlich mehr Arbeit,
ist separat als Feature fuer spaeter zu sehen. Live-Sync bei geoeffneter
App (WebSocket verbunden) funktioniert jetzt zuverlaessig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:53:47 +02:00
duffyduckandClaude Opus 4.7 47fe4ad655 fix: Modus-Wechsel via App/ID funktioniert jetzt
Bug: App ModeSelector sendet rvs.send('mode', { mode: 'normal' })
mit ID, Bridge's detect_mode_switch() sucht aber nach Aktivierungs-
phrasen wie 'aria, normal-modus' → kein Match → Modus-Wechsel
wurde ignoriert, TTS-Verhalten blieb auf NORMAL haengen.

Fix:
- modes.py: mode_from_id() mappt IDs zu Mode-Enum
  ('normal', 'dnd', 'nicht_stoeren', 'fluester', 'whisper',
   'hangar', 'gaming' — flexibel)
- aria_bridge.py: mode-Handler versucht erst ID-Mapping, dann
  Phrasen-Erkennung als Fallback
- Unbekannte Modi werden geloggt
- Bestaetigung wird an alle Clients zurueckgesendet damit App-UI
  synchron bleibt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:51:18 +02:00
duffyduckandClaude Opus 4.7 99cb83202e feat: App XTTS-Voice-Auswahl + Aufnahme + Loeschen (geraetelokal)
App Settings: Voice-Sektion (nur wenn TTS an)
- Liste aller XTTS-Server-Stimmen mit Auswahl-Radio + X zum Loeschen
- 'Standard' fuer Diagnostic-Default-Voice (keine lokale Ueberschreibung)
- 'Aktualisieren' Button laedt Liste neu (xtts_list_voices via RVS)
- 'Eigene Stimme aufnehmen' oeffnet VoiceCloneModal

VoiceCloneModal: 30s Aufnahme + Upload
- Vorlese-Text (>30s Lesedauer, thematisch passend)
- Rot-pulsierender Stop-Button, live Timer + Progressbar
- Auto-Stop bei 30s, Hinweise ab 15s ('genug fuer gute Clonung')
- Nach Stop: Namenseingabe (a-Z, 0-9, _, -), Upload via voice_upload
- Nach Upload: Modal schliesst, Settings bekommt xtts_voice_saved
  und setzt automatisch die neue Stimme als gewaehlt

Voice-Flow App → Bridge → XTTS (geraetelokal):
- Jeder chat/audio/tts_request schickt aria_xtts_voice (AsyncStorage)
  mit der Message mit
- Bridge speichert _next_voice_override bei chat/audio Empfang,
  nutzt es fuer die naechste ARIA-Antwort und resettet dann
- Fallback: globale xtts_voice aus voice_config.json (Diagnostic)

Ergebnis:
- Gerat A hat 'stefan' geclont → ARIA antwortet Geraet A mit stefan
- Gerat B hat nichts gewaehlt → ARIA antwortet Geraet B mit Default
- Diagnostic-Einstellung wirkt als fallback-default fuer neue Geraete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:48:24 +02:00
duffyduckandClaude Opus 4.7 fc2438be2d fix/feat: XTTS-Voice korrekt persistiert, Loeschen + Voice-per-Request
Bug-Fix: Voice-Auswahl verschwand nach Page-Load
- xtts_voices_list Handler rebuildet das Dropdown — vorheriger select.value
  ging dabei verloren. Jetzt wird der Wert gemerkt und nach Rebuild
  wiederhergestellt (falls die Stimme noch existiert).

Feature: Stimmen loeschen (Diagnostic)
- XTTS-Bridge: neuer handleDeleteVoice — entfernt /voices/<name>.wav
  und schickt aktualisierte Liste per xtts_voices_list
- RVS: xtts_delete_voice in ALLOWED_TYPES
- Diagnostic Server: Action xtts_delete_voice forwarded via RVS
- Diagnostic UI: renderVoiceList zeigt alle Custom-Voices mit X-Button
  Bei Loeschen der gerade aktiven Stimme: auf Default zuruecksetzen

Feature: Voice-per-Request in Bridge
- App kann mit jedem Chat ein voice-Feld mitschicken
- Bridge merkt sich _next_voice_override, nutzt es fuer die NAECHSTE
  ARIA-Antwort (einmalig, dann reset)
- tts_request (Play-Button) akzeptiert voice im Payload als Override
- Fallback: globale xtts_voice aus voice_config.json
- So kann jedes Geraet seine eigene Stimme haben ohne den globalen
  Default zu aendern

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:43:26 +02:00
duffyduckandClaude Opus 4.7 40e48b046b feat: App TTS-Einstellungen vereinfacht + Mund-Button fuer lokales Muten
SettingsScreen:
- Piper-Reste entfernt (defaultVoice, highlightVoice, Speed-Slider,
  Highlight-Trigger-Info)
- Nur noch EIN Toggle 'Sprachausgabe auf diesem Geraet' — geraetelokal,
  persistent in aria_tts_enabled (AsyncStorage)
- Keine Config-Propagation mehr via RVS (das waere ja global gewesen)
- Hinweis dass Stimme + Voice-Cloning zentral in der Diagnose sind

ChatScreen: Mund-Button (👄 / 🤐)
- Neben Ohr-Button im Eingabebereich, NUR sichtbar wenn TTS im Setting
  grundsaetzlich aktiv ist
- Tap toggelt Mute: 👄 an / 🤐 rot gemutet
- Persistent in aria_tts_muted (AsyncStorage)
- Stoppt bei Muten sofort laufende Wiedergabe (stopPlayback)
- Settings-Toggle wird alle 2s gepollt damit Aenderungen greifen
  (einfache Loesung ohne globalen State-Context)

Audio-Handling respektiert lokalen Zustand
- Incoming audio/audio_pcm: nur abspielen wenn ttsDeviceEnabled && !ttsMuted
- Cache wird TROTZDEM immer geschrieben — Play-Button funktioniert
  spaeter aus Cache, auch waehrend Mute
- audioService.handlePcmChunk akzeptiert silent-Flag: skipt AudioTrack
  aber baut weiterhin den WAV-Cache pro messageId

Jedes Android-Geraet mit der App hat seinen eigenen Mute-Zustand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:33:36 +02:00
duffyduckandClaude Opus 4.7 f801d99748 feat: Piper komplett entfernt — nur noch XTTS v2 als TTS
Breaking Change: wenn XTTS-Bridge (Gaming-PC) offline ist, bleibt ARIA
stumm. Chat-Antworten kommen weiter an, aber kein Audio. Das ist
bewusst akzeptiert — XTTS klingt einfach grauenhaft viel besser.

Bridge (aria_bridge.py):
- from piper import ... raus
- VoiceEngine-Klasse komplett entfernt (synthesize, speak, select_voice)
- EPIC_TRIGGERS + load_epic_triggers raus (Highlight-Voice-Feature
  ohne Piper sinnlos)
- self.voice_engine, voice_name, requested_voice Aufrufe weg
- _process_core_response: immer XTTS, kein Fallback
- tts_request Handler: immer XTTS
- config Handler: nur ttsEnabled + xttsVoice + whisperModel
- import wave raus

bridge/requirements.txt: piper-tts raus
bridge/Dockerfile: Kommentar aktualisiert
docker-compose.yml: ./aria-data/voices Mount raus
aria-data/config/aria.env.example: PIPER_RAMONA/PIPER_THORSTEN raus
get-voices.sh: komplett geloescht (war nur Piper-Downloader)

Diagnostic UI (index.html):
- Piper Panel (Standard-Stimme / Highlight-Stimme / Speed-Sliders) weg
- TTS Engine Dropdown weg (immer XTTS)
- TTS Diagnose Tab zeigt nur noch XTTS-Status + Test-Button
- sendVoiceConfig sendet nur noch ttsEnabled/xttsVoice/whisperModel
- toggleXTTSPanel als no-op Legacy-Stub (JS-Calls bleiben safe)

Diagnostic Server (server.js):
- handleSendVoiceConfig: nur noch ttsEnabled + xttsVoice + whisperModel
- handleTestTTS: via xtts_request (nicht mehr Piper subprocess)
- handleCheckTTS: via xtts_list_voices ueber RVS
- handleGetVoiceConfig/Defaults bereinigt
- Highlight-Trigger UI bleibt, wird aber von Bridge nicht mehr
  ausgewertet (dead-code im UI, spaeter ggf. fuer XTTS-Voice-Switch)

README + issue.md aktualisiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:24:42 +02:00
duffyduckandClaude Opus 4.7 6ab6196739 feat: Streaming TTS — PCM-Stream statt WAV-Chunks (Weg A)
Pipeline: XTTS-Server → xtts-bridge → aria-bridge → RVS → App AudioTrack

XTTS-Bridge (Gaming-PC):
- streamXTTSAsPCM(): liest /tts_to_audio/ Response inkrementell,
  parst WAV-Header (samplerate/channels), teilt PCM in 8KB-Chunks
  (~170ms bei 24kHz s16 mono) und sendet jeden als audio_pcm.
- Finaler Chunk mit final=true nach letztem Text-Chunk

aria-bridge:
- audio_pcm Handler leitet payload 1:1 weiter, filled messageId aus
  requestId → messageId Map falls XTTS-Bridge messageId nicht hatte
- Alter xtts_response Pfad bleibt als Legacy-Fallback (WAV)

RVS: audio_pcm in ALLOWED_TYPES

Android Native:
- PcmStreamPlayerModule (Kotlin): AudioTrack MODE_STREAM mit
  Writer-Thread und BlockingQueue. start(rate, ch) / writeChunk(b64)
  / end() / stop()
- 8x MinBufferSize grosszuegig dimensioniert, glatt auch bei
  Netz-Aussetzern
- Registered im MainApplication via PcmStreamPlayerPackage

App JS:
- audioService.handlePcmChunk(): erkennt neue Session (messageId-Wechsel),
  started nativen Stream, cached PCM-Bytes pro Message. Bei final=true
  Stream sauber schliessen + _savePcmBufferAsWav → WAV-File im
  tts_cache/<messageId>.wav
- _savePcmBufferAsWav: baut 44-byte WAV-Header (PCM s16le, korrekte
  samplerate/channels), haengt alle gesammelten base64-PCM-Chunks an
- stopPlayback beendet auch aktiven PCM-Stream
- ChatScreen routet type=audio_pcm an handlePcmChunk, bei final
  setzt audioPath in der Message

Play-Button: falls messageId einen audioPath hat → WAV aus Cache
(Sound-basiert), egal ob Original-TTS Piper oder XTTS war.

Audio-Focus:
- requestDuck() beim Stream-Start, release() bei Stream-Ende
- Andere Apps (Spotify etc.) werden leiser waehrend ARIA spricht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:01:27 +02:00
duffyduckandClaude Opus 4.7 eb12281dfc feat: TTS-Zeitbereiche + Diagnostic-Debug-Toggle + Play-Button respektiert Engine
TTS-Cleanup erweitert:
- Zeitbereiche: '8:00-9:00 Uhr' / '8-9 Uhr' → 'acht bis neun Uhr'
- Uhrzeiten: '8:30 Uhr' → 'acht Uhr dreissig', '15 Uhr' → 'fuenfzehn Uhr'
- Kleine Zahlen-Bereiche: '5-6' → 'fuenf bis sechs' (nur ≤24)
- Zahlen 0-59 als deutsche Woerter (inkl. 'einundzwanzig', 'fuenfundvierzig')

Diagnostic: TTS-Debug Einblenden
- Checkbox 'TTS-Text einblenden' in der Chat-Test Kopfzeile
- Unter ARIA-Nachrichten erscheint die aufbereitete Variante
  (blauer Border + Label 'TTS:')
- Nur in Diagnostic, nicht in der App
- LocalStorage persistiert den Toggle-Zustand
- Minimaler JS-Port von clean_text_for_tts als Fallback

Play-Button respektiert Engine:
- Bridge: tts_request nutzt jetzt die aktive TTS-Engine (Piper/XTTS),
  Text wird durch clean_text_for_tts aufbereitet
- messageId wird vom Play-Button mitgeschickt → Bridge verknuepft
  generiertes Audio mit der urspruenglichen Message
- XTTS-Chunks: requestId → messageId Map (LRU 100 Eintraege),
  beim xtts_response wird die Basis-UUID extrahiert und die
  messageId dem audio-Frame angehaengt
- App cached auch XTTS-Audio jetzt (letzter Satz pro Message —
  echte Chunk-Konkatenation bleibt TODO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:48:32 +02:00
duffyduck 1fb1fdef9e release: bump version to 0.0.4.1 2026-04-19 21:00:49 +02:00
duffyduckandClaude Opus 4.7 593d26e0ff fix: QR-Code overflowed Container — auf SVG umgestellt mit width/height 100%
Der QR wurde mit createImgTag() als fester Pixel-IMG gerendert und
ueberlappte den Warnhinweis + Button rechts daneben. Fix:

- createSvgTag mit cellSize=4 + scalable=true
- SVG skaliert auf width:100%/height:100% der 220x220 Box
- Container: flex-shrink:0 (damit Flex ihn nicht weiter schrumpft)
- overflow:hidden als Sicherheit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:28:41 +02:00
duffyduckandClaude Opus 4.7 394abb58be fix: Runtime-Config Layout + Eye-Toggle fuer Token-Felder
- Eingabefelder haben jetzt width:100% + box-sizing:border-box,
  keine Ueberlappung mehr im Grid
- Token-Felder haben einen Augen-Button daneben (👁/👀) zum
  Anzeigen/Verbergen des Inhalts
- Kleineres Label-Grid (140px statt 150px), grosszuegigerer Gap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:27:56 +02:00
duffyduckandClaude Opus 4.7 fc3bee6d05 feat: Runtime-Config via Diagnostic UI — kein .env-Sync mehr
Framework fuer zentrale Runtime-Konfiguration:
- /api/runtime-config (GET/POST) persistiert in /shared/config/runtime.json
- Werte haben Vorrang ueber die ENV-Variablen aus aria.env
- Feldliste: RVS_HOST/PORT/TLS/TOKEN, ARIA_AUTH_TOKEN, WHISPER_MODEL/LANGUAGE
- Atomic write (tmp + rename) fuer Konsistenz

Bridge:
- load_config() liest nach aria.env noch runtime.json und ueberschreibt
  die Werte. Aenderungen werden beim Neustart der Bridge uebernommen.

Diagnostic UI:
- Neue Sektion "Runtime-Konfiguration" in Einstellungen
- Formular fuer RVS-Credentials + Aria-Auth-Token
- "Speichern" persistiert, triggert auch QR-Code-Regenerierung
- Hinweis: Diagnostic-Container selbst bleibt auf ENV (erstmal)

issue.md konsolidiert — 6 groessere Tasks dieser Session als erledigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:18:37 +02:00
duffyduckandClaude Opus 4.7 b203503fd8 feat: QR-Code Onboarding + TTS-Audio-Cache im Filesystem
QR-Code Onboarding
- Diagnostic: GET /api/onboarding gibt RVS-Credentials zurueck
- Einstellungen-UI: neue Sektion mit QR-Code (qrcode-generator via CDN)
- Format kompatibel mit bestehendem QRScanner.parseQRData (host/port/tls/token)
- App-SettingsScreen hatte QR-Scanner bereits — funktioniert out of the box
- Warnhinweis zu Token im Klartext

TTS-Audio-Cache
- Bridge: jede ARIA-Chat-Nachricht bekommt eine messageId (UUID)
  Audio-Payload wird mit messageId verknuepft (Piper-Pfade)
- ChatScreen: messageId + audioPath in ChatMessage Interface
- audioService.cacheAudio(): speichert Base64 in DocumentDirectory/tts_cache/<id>.wav
- audioService.playFromPath(): spielt aus Cache ohne Regenerierung
- Play-Button: wenn audioPath gesetzt → aus Cache, sonst tts_request
- cleanupOldTTSCache(): alte unreferenzierte WAVs (>30 Tage) weg
- Persistiert via AsyncStorage — ueberlebt App-Restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:16:25 +02:00
duffyduckandClaude Opus 4.7 8b0a72dc9b feat: NO_REPLY-Filter + Audio-Ducking + TTS-Cleanup
1) NO_REPLY Token wird in Bridge und Diagnostic erkannt und still
   verworfen. Toleranz fuer Variationen (Whitespace, Punkt, Quotes).
   Kein Chat-Eintrag, kein TTS.

2) AudioFocusModule (Kotlin) mit requestDuck / requestExclusive /
   release. AudioService ruft:
   - requestExclusive() bei Aufnahme-Start → andere Apps pausieren
   - requestDuck() bei TTS-Playback-Start → andere Apps leiser
   - release() bei Stop/Queue-Ende
   MainApplication registriert AudioFocusPackage.

3) clean_text_for_tts() in Bridge — zentrale Aufbereitung:
   - <voice>...</voice> Tag wird bevorzugt (falls ARIA es schreibt)
   - Code-Bloecke (``` und `) komplett raus
   - Markdown (Fett/Kursiv/Links/Headings/Listen) geschleift
   - Einheiten ausgeschrieben: 22GB → 22 Gigabyte, 85% → 85 Prozent
   - Abkuerzungen buchstabiert: CPU → C P U, API → A P I
   - URLs durch "ein Link" ersetzt
   Genutzt in VoiceEngine.synthesize und im XTTS-Request — Chat-Text
   an die App bleibt unveraendert (original Markdown), nur TTS kriegt
   die aufbereitete Version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:10:54 +02:00
duffyduckandClaude Opus 4.7 23add7a107 docs: Neue Issues - TTS-Cleanup, Audio-Ducking, Config-UI, NO_REPLY-Bug
- Bug: NO_REPLY wird als "NO" angezeigt
- Audio-Ducking + Mute waehrend Aufnahme
- Spracheingabe-Timeout erhoehen
- TTS-Text-Aufbereitung (Code raus, Einheiten ausschreiben)
- Audio-Cache in Messages (kein Regenerieren beim Play-Button)
- Piper evtl. entfernen
- .env → Diagnostic UI migrieren
- QR-Code Onboarding
- XTTS Web-Oberflaeche / zentral via Diagnostic
- Root-Cause OpenClaw Session-Reset pruefen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:47 +02:00
duffyduckandClaude Opus 4.7 caf84196fb fix: Reset-File-Regex - Timestamp endet mit Z (ohne Punkt davor)
Die OpenClaw Reset-Files heissen <uuid>.jsonl.reset.<iso>Z
(nicht <uuid>.jsonl.reset.<iso>.Z). Der falsche Regex matchte
nie, alle Archive wurden als "verwaist" angezeigt statt als "archiv".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:32:24 +02:00
duffyduck 099b9651a6 release: bump version to 0.0.4.0 2026-04-18 12:28:08 +02:00
duffyduckandClaude Opus 4.7 76d72a1eef feat: Archivierte Session-Versionen (OpenClaw .reset.* Files) in Diagnostic
OpenClaw resettet Sessions beim ersten chat.send nach Container-Restart
(wenn abortedLastRun / systemSent Inkonsistenz erkannt wurde) und
benennt die alte .jsonl in .jsonl.reset.<timestamp>.Z um. Der Inhalt
war also gar nicht verloren, nur unsichtbar.

Diagnostic:
- handleListSessions scannt jetzt auch *.jsonl.reset.* Files
- Reset-Files bekommen archived:true + resetAt-Timestamp
- Neue UI-Sektion "Archivierte Versionen" (collapsible <details>)
  mit Export-Button, zeigt aufklappbar alle gesicherten alten Sessions
- Aktivieren ist fuer Archive deaktiviert (zerstoert aktive Session)
- Loeschen + Export stehen zur Verfuegung

tools/export-jsonl-to-md.js:
- Standalone Node-Script zum Konvertieren beliebiger .jsonl (auch reset-Files)
- Nutzbar via stdin, exakt gleiche Export-Logik wie Diagnostic
- Fuer Rettungsaktionen direkt auf der VM

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:24:54 +02:00
duffyduckandClaude Opus 4.7 87deede078 fix: Session Msgs-Counter zaehlt echte Nachrichten, nicht alle Zeilen
Vorher: wc -l auf der .jsonl — zaehlt auch Tool-Calls, Run-Events,
Metadata-Eintraege mit. Diagnostic zeigte z.B. "10 Msgs" fuer eine
Session mit 6 echten User/Assistant-Nachrichten.

Jetzt: grep -cE '"role":"(user|assistant)"' — zaehlt nur echte
Konversations-Messages. Matcht wie der Export und die Chat-History
das interpretieren.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:09:01 +02:00
duffyduckandClaude Opus 4.7 6fec8588c1 fix: Gespraechsmodus - strenger Speech-Gate + Crash-Prevention
Probleme:
- Hintergrundgeraeusche wurden als Sprache erkannt und an Whisper geschickt
- App stuerzte nach laengerem Zuhoeren ab (OOM / Cache-Ueberlauf)

Aenderungen:
- VAD_SPEECH_THRESHOLD_DB -35 -> -28 (filtert Raum-Ambient)
- VAD_SPEECH_MIN_MS 300 -> 500 (keine Huestler/Klopfer mehr)
- Max-Aufnahmedauer 30s (Notbremse gegen Runaway-Loops)
- _cleanupStaleCacheFiles(): alte aria_recording_/aria_tts_ Files (>30s)
  werden vor jeder neuen Aufnahme geloescht
- ChatScreen: capMessages() begrenzt Messages-Array auf 500 Eintraege
  (OOM-Schutz in langen Gespraechen)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:05:15 +02:00
duffyduckandClaude Opus 4.7 aafdbcd57a fix: Thinking indicator beim Seitenladen nicht mehr sichtbar
Zwei display:-Deklarationen im inline-style der Diagnostic-Chat-Leiste
haben sich gegenseitig ueberschrieben — 'display:flex' war die zweite
und hat 'display:none' aushebelt. Indicator war so beim Seitenaufbau
sichtbar bis JS ein idle-Event empfing.

- HTML: 'display:flex' aus inline-style entfernt
- JS: beim Anzeigen explizit display='flex' setzen (statt 'block')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:58:39 +02:00
duffyduck 08da28f475 release: bump version to 0.0.3.9 2026-04-18 11:52:53 +02:00
duffyduckandClaude Opus 4.7 8c1014d281 fix: Thinking indicator respringt nach chat:final durch trailing events
Nach chat:final kommen oft noch agent-Events rein (Core raeumt nach),
die den Thinking-Indicator wieder anspringen liessen.

- Diagnostic: 3s-Settled-Window nach chat:final, agent_activity-Broadcasts
  werden in dem Fenster unterdrueckt (idle kommt weiter durch).
- Bridge: Gleiches Fenster in _emit_activity() — App bekommt keine
  trailing thinking/tool-Events mehr nach dem finalen Antwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:51:22 +02:00
duffyduckandClaude Opus 4.7 271fc4edf6 docs: cleanup.sh + README updates for latest features
- cleanup.sh: sicherer (default) + aggressiver (--full) Docker-Cleanup
  mit Speicher-Report vor/nach
- README: Phase-1-Liste, Diagnostic-Features und App-Features um die
  neuen Punkte ergaenzt (Speech Gate, Session-Persistenz, Session-Export,
  App Thinking-Indicator, Whisper-Modellauswahl, 16kHz-Aufnahme)
- README: Neuer Abschnitt "Docker-Cleanup" mit cleanup.sh Usage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:46:12 +02:00
duffyduck cd390a4115 release: bump version to 0.0.3.8 2026-04-18 11:41:12 +02:00
duffyduckandClaude Opus 4.7 a65ed579d2 feat: Whisper model selector + 16kHz mono recording
- App: AudioSamplingRateAndroid 16000 + AudioChannelsAndroid 1
  → Whisper bekommt direkt sein Ziel-Format, kein Resample mehr
- Bridge: STTEngine.reload() laedt Modell zur Laufzeit neu
  (tiny/base/small/medium/large-v3)
- Bridge: Config-Message triggert Hot-Reload wenn whisperModel sich aendert
- Bridge: Default auf 'medium' (besser als 'small' bei aehnlicher Latenz)
- Diagnostic: Neue Sektion "Whisper (Spracherkennung)" mit Dropdown,
  auto-save bei Auswahl, beim Laden wird der gespeicherte Wert gesetzt
- Diagnostic/Server: send_voice_config merged whisperModel in voice_config.json
- aria.env.example: WHISPER_MODEL + WHISPER_LANGUAGE dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:37:27 +02:00
duffyduckandClaude Opus 4.7 2ad1f57382 feat: Thinking indicator + cancel button in the app
- Bridge: _emit_activity() spiegelt OpenClaw agent events als agent_activity
  an RVS, dedupliziert State-Wechsel. chat:final/error senden idle.
- Bridge: Neuer cancel_request-Handler ruft Diagnostic /api/cancel per HTTP.
- Diagnostic: Neuer POST /api/cancel Endpoint (gleiche Logik wie WS-Cancel).
- RVS: agent_activity + cancel_request in ALLOWED_TYPES.
- App: Gelber Indicator ueber der Input-Bar mit Text je nach Activity,
  roter Abbrechen-Button. Cancel sendet cancel_request via RVS.
- issue.md: Erledigte Bugfixes + Features konsolidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:22:02 +02:00
duffyduckandClaude Opus 4.7 58e3cfd3e6 feat: Session export as markdown in Diagnostic
- ⬇ Button per Session-Zeile — exportiert auch inaktive Sessions
- Server parst JSONL, extrahiert User/Assistant-Nachrichten mit Timestamp
- Metadata-Prefix wird entfernt, Markdown mit # Session-Header generiert
- Browser-Download via Blob + download-Attribut

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:14:15 +02:00
duffyduckandClaude Opus 4.7 7de4ee8f5b fix: Stuck "ARIA denkt..." indicator after pipeline ends
- pipelineEnd() now broadcasts agent_activity: idle unconditionally
- chat:error and chat:final paths broadcast idle outside of active pipeline
- Gateway close event ends active pipeline + broadcasts idle
- Prevents indicator from hanging after timeout/error/disconnect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:11:12 +02:00
duffyduckandClaude Opus 4.7 213edac3a7 fix: Session persistence - respect user choice across container restarts
- sessionFromFile flag prevents auto-pick after first start
- Atomic write (temp + rename) with loud error logging
- Auto-pick filters out aria-bridge/aria-diagnostic when user sessions exist
- handleSetActiveSession reports persistence failures to client

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:03:26 +02:00
duffyduckandClaude Opus 4.6 acc13aef6b fix: Speech gate - only send recording if actual speech detected
- VAD_SPEECH_THRESHOLD_DB = -35 (louder than silence threshold)
- Needs 300ms of speech before counting as real speech
- Recording discarded if only background noise detected
- Prevents sending garbage to Whisper in conversation mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:20:05 +02:00
duffyduck 4bbc6f7787 release: bump version to 0.0.3.7 2026-04-11 13:18:17 +02:00
duffyduckandClaude Opus 4.6 20f2ea1829 fix: Conversation mode starts recording immediately when ear button tapped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:15:26 +02:00
duffyduckandClaude Opus 4.6 2d23f0668b docs: update README with conversation mode, multi-attachments, markdown cleanup
- Conversation mode (ear button) documented in App Features
- Multiple attachments + paste support
- Markdown cleanup for TTS
- Auto-Update FileProvider + check button
- Roadmap: 22 items in Phase 1 completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:43:09 +02:00
duffyduckandClaude Opus 4.6 d6030a06b7 docs: update issue.md - move completed items, clean up open list
28 items completed, 10 remaining open

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:23:04 +02:00
duffyduck 0df76e2af6 release: bump version to 0.0.3.6 2026-04-11 12:19:00 +02:00
duffyduckandClaude Opus 4.6 f80fe1df93 fix: Inverted FlatList - newest messages always visible at bottom
- No more scrollToEnd/scrollToIndex needed
- FlatList inverted=true with reversed data
- New messages appear at bottom automatically
- User scrolls up to see history (natural chat behavior)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:17:32 +02:00
duffyduck cff421bc53 release: bump version to 0.0.3.5 2026-04-11 12:13:41 +02:00
duffyduckandClaude Opus 4.6 bca925d385 fix: Use scrollToIndex with viewPosition:1 for reliable bottom scroll
- scrollToIndex targets last message at bottom of viewport
- onScrollToIndexFailed fallback to scrollToEnd
- More reliable than scrollToEnd with dynamic heights

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:12:24 +02:00
duffyduck 9abde89805 release: bump version to 0.0.3.4 2026-04-11 12:09:23 +02:00
duffyduckandClaude Opus 4.6 ea4f639fcb fix: Auto-scroll retry with multiple delays (100, 300, 600, 1000ms)
FlatList needs time to render - single setTimeout(150) was unreliable.
Now tries 4 times on initial load, 2 times for new messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:07:54 +02:00
192 changed files with 50660 additions and 3646 deletions
+15
View File
@@ -0,0 +1,15 @@
# Wo erreicht die Dev-Maschine die aria-wohnung VM?
# Kopiere diese Datei nach .claude/aria-vm.env und passe die IP an.
# .claude/aria-vm.env ist gitignored (lokal pro Maschine).
#
# Verwendung in Bash:
# source .claude/aria-vm.env
# curl -s "$ARIA_BRAIN_URL/memory/stats"
#
# Im docker-compose-Netz aria-net laufen die Hostnamen ohnehin direkt
# (aria-brain, aria-bridge, aria-qdrant). Diese Datei brauchen nur
# Hosts AUSSERHALB der VM (z.B. die Dev-Maschine wo Claude Code laeuft).
ARIA_VM_HOST=192.0.2.1
ARIA_DIAG_URL=http://192.0.2.1:3001
ARIA_BRAIN_URL=http://192.0.2.1:3001/api/brain
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh root@172.0.2.33 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+25
View File
@@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
# Alle muessen den gleichen Host, Port und Token nutzen.
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern.
# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc.
# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}.
RVS_HOST=rvs.example.de
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
RVS_PORT=443
# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen.
# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator
# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer
# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard
# passen. Leer/ungesetzt = RVS_PORT wird verwendet.
RVS_PORT_PUBLIC=
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
RVS_TLS=true
@@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
RVS_TOKEN=
# ── Brain-Timeouts ───────────────────────────────
# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming
# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call
# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern.
# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn
# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog
# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet)
# der den Subprocess killt wenn wirklich was haengt.
# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird.
PROXY_TIMEOUT_SEC=86400
# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig.
# PROXY_CONNECT_TIMEOUT_SEC=10
# PROXY_WRITE_TIMEOUT_SEC=30
# PROXY_POOL_TIMEOUT_SEC=10
# ── Gitea — Release-Verwaltung ───────────────────
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
+36 -9
View File
@@ -9,15 +9,39 @@
.env.*
!.env.example
!.env.*.example
aria-data/config/*.env
!aria-data/config/*.env.example
!aria-data/config/openclaw.env
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
aria-data/brain/
# Lokale Dev-Maschinen-Settings fuer Claude Code (z.B. wie erreicht die
# Dev-Maschine die aria-wohnung-VM). .example ist Repo-Inhalt, echte
# Werte pro Maschine selbst pflegen.
.claude/*.env
!.claude/*.env.example
# ── Stimmen (große Binärdateien) ─────────────────
aria-data/voices/
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
# fertig. Die MDs gehoeren NICHT ins Repo (koennen private Daten enthalten,
# sind eh ephemeral). Verzeichnis selbst bleibt im Git via .gitkeep,
# README erklaert den Zweck.
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/
aria-data/brain/qdrant/
# Diagnostic-State (aktive Session etc.)
aria-data/config/diag-state/
# ── Shared Volume (Bind-Mount statt Docker-managed) ──
# Enthaelt User-Uploads, Voice-Cloning-Samples, OAuth-Tokens,
# chat_backup.jsonl, Memory-Attachments, runtime-state. Hunderte MB,
# enthaelt PRIVATE Daten. Backup via Diagnostic, nicht via Git.
aria-shared/
# ── Node / npm ──────────────────────────────────
node_modules/
@@ -46,7 +70,6 @@ desktop/dist/
__pycache__/
*.pyc
*.pyo
bridge/__pycache__/
# ── macOS ────────────────────────────────────────
.DS_Store
@@ -55,4 +78,8 @@ bridge/__pycache__/
.vscode/settings.json
.idea/
*.swp
*.swo
*.swo
# Lokale LLM-Modelle (Plan B) — GGUF/HF-Cache sind mehrere GB, nicht ins Repo
xtts/models/*
!xtts/models/.gitkeep
+310
View File
@@ -2,6 +2,316 @@
Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
> **Hinweis:** Dieser Changelog hatte eine große Lücke — er endete bei `0.0.0.5`
> (2026-03), das Projekt lief aber bis `0.2.0.2` (2026-07) weiter (u. a. OAuth,
> Voice-Streaming, Speaker-ID, Datei-Manager). Ab dem Projekte-/Multi-Threading-
> Epos (2026-07) wird wieder gepflegt; die dazwischenliegenden Versionen
> `0.0.0.6``0.1.9.6` sind nicht rückwirkend nacherfasst.
---
## [0.2.3.0] — 2026-07-19 — Satelliten: ARIAs Augen & Hände in fremden Netzen 🛰️
### Hinzugefügt
**Neuer eigenständiger Container `satellite/`** — ein Außenposten, den du in einem beliebigen Netz (Büro, Werkstatt …) deployst. Er verbindet sich als RVS-Client in deinen Raum und gibt ARIA Zugriff auf **genau dieses Netz**, ohne dass der Haupt-Stack dort steht.
- **Entdeckung (Info):** mDNS/Zeroconf (Chromecast, AirPlay, Sonos, Drucker, NAS …), SSDP/UPnP + **DIAL** (Smart-TVs, Fire TV), ARP-Tabelle (rohe Hosts) → Live-Inventar.
- **Steuerung (mit Guards):** **DIAL-App-Launch** (z.B. „ARIA, spiel YouTube-Video X auf dem Büro-Stick" → Fire TV), **Wake-on-LAN**, generisches **HTTP**. Nur wenn `CONTROL_ENABLED=true`, nur Aktionen aus der `CONTROL_ALLOWLIST`, alles geloggt, read-only per `.env` abschaltbar. Reagiert nur auf den eigenen RVS-Raum (Token). Keine offenen Ports.
- **Adressierung** über `SATELLITE_LOCATION` (z.B. „Büro") — mehrere Satelliten im selben Raum, jeder mit eigenem Namen.
**End-to-end verdrahtet:**
- `satellite/`: eigener Stack (`docker compose` mit `network_mode: host`), `.env.example`, README.
- RVS: neue Message-Typen `sat_hello / sat_discover / sat_devices / sat_command / sat_result`.
- Bridge: Satelliten-Registry (`sat_hello`) + Future-Relay (`/internal/satellite`, `/internal/satellite-list`) analog zum flux-Muster.
- Brain: Tools `satellite_list`, `satellite_devices`, `satellite_command` + Seed-Regel, die ARIA den Ablauf beibringt (erst list, dann devices, dann command).
- **Diagnostic: neuer Tab „Satelliten"** — zeigt live welche Satelliten verbunden sind (online/offline, Standort, Capabilities, read-only vs. steuerbar) und pro Satellit ein „Geräte scannen" (löst `sat_discover` aus → erkannte Geräte mit Typ/IP/Modell/DIAL/MAC).
### Deploy
Satellit im Ziel-Netz: `cd satellite && cp .env.example .env && docker compose up -d --build`. Haupt-Stack: `git pull && docker compose up -d --build brain bridge` + RVS-Stack `up -d --build`. Kein APK-Rebuild.
---
## [0.2.2.3] — 2026-07-17 — ARIA liest andere Projekt-Chats wirklich (volle Historie)
### Behoben
- **`project_summary` fand fast nie Inhalte.** Es las nur ARIAs rollendes Kontextfenster (~50 Turns über ALLE Projekte) — ältere/andere Projekt-Chats sind da längst rausdistilliert. „Hol dir die Infos aus Projekt X" lieferte deshalb leere Ergebnisse, und ARIA wusste nicht, wie sie an die Historie kommt. Jetzt liest das Tool die **echte, volle Historie aus `chat_backup.jsonl`** (im Brain gemountet) — die letzten ~20 Turns des Zielprojekts, unabhängig davon wie lange man da nicht war. Standort-Hints in User-Turns werden rausgefiltert. Fallback aufs Fenster, falls das Backup fehlt. Tool-Beschreibung geschärft, damit ARIA es direkt aufruft.
- Nebenbei bereinigt: `mac_os_update_fehler` war durch den alten `set_project_kind`-Bug (0.2.2.1) fälschlich `kind=code` — auf `chat` zurückgesetzt.
### Deploy
`git pull && docker compose up -d --build brain` (kein APK-Rebuild nötig).
---
## [0.2.2.2] — 2026-07-17 — Voice-Projektwechsel zurück — aber gerätelokal
### Hinzugefügt
- **„ARIA, geh in Projekt X" per Sprache wechselt wieder das Projekt** — aber **nur auf dem Gerät, das den Befehl gab**. Andere App-Instanzen und das Diagnostic bleiben in ihrem Projekt.
- Umsetzung: Jedes Gerät merkt sich die IDs seiner eigenen Anfragen (Text-`clientMsgId` + Voice-`audioRequestId`). Der Brain/Voice-Router hängt an jedes `project_changed`-Event die auslösende `clientMsgId` an; die App folgt dem Wechsel nur, wenn die ID eine **eigene** ist. Deckt beide Voice-Pfade ab (ARIAs `project_enter`/`exit` via `send_to_core` **und** den Bridge-Voice-Router „für Projekt X: …" / „zurück zum Hauptchat").
- Manuelles Umschalten bleibt wie gehabt gerätelokal über den Drawer.
### Deploy
`git pull && docker compose up -d --build bridge` + APK 0.2.2.2 (kein Brain-Rebuild nötig).
---
## [0.2.2.1] — 2026-07-17 — Projekt-Fokus wirklich pro Gerät unabhängig
### Behoben
- **`set_project_kind` markierte das falsche Projekt.** Es nutzte den **globalen** `active_project` (geräteübergreifend) statt des Projekts, in dem der aktuelle Request läuft. Folge: Obwohl auf der App „Basic os" aktiv war, bekam das global-aktive „Mac OS Update Fehler" `kind=code`. Jetzt wirkt das Tool auf die pro Request mitgesendete `project_id` (in `_dispatch_tool` durchgereicht) — jede App-/Diagnostic-Instanz markiert ihr eigenes Projekt.
- **App wurde von fremden `project_changed`-Broadcasts ins andere Projekt gezogen.** Ein Projektwechsel/anlegen durch ARIA, das Diagnostic oder eine andere App-Instanz erzwang auf **allen** Geräten einen Fokuswechsel. Der Projekt-Fokus ist jetzt **rein gerätelokal**: Broadcasts aktualisieren nur Namen + Typ, wechseln aber nicht mehr das Projekt. Umschalten geht ausschließlich lokal über den Projekt-Drawer. So arbeitet jedes Gerät unabhängig in seinem eigenen Projekt (auch mehrere App-Instanzen).
- Hinweis: „ARIA, geh in Projekt X" per Sprache wechselt das App-Projekt dadurch **nicht mehr** automatisch — bewusst, damit Geräte unabhängig bleiben. Wechseln über den Drawer.
### Deploy
`git pull && docker compose up -d --build brain` + APK 0.2.2.1 neu bauen.
---
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
### Geändert
**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas**
- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*.
- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand.
- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area).
- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist.
- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails.
**noVNC-Konsole endlich bedienbar**
- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus.
### Deploy
Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
---
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
### Hinzugefügt
- **Ansichts-Umschalter im Header** („⧉ Kompakt" / „⧉ Cockpit"): Die App startet in **Kompakt** — der klassische Vollbild-Chat, **exakt wie vor dem Umbau** (Default, Mama-tauglich). Ein Tap auf den Button oben rechts schaltet auf **Cockpit** — den zoom-/verschiebbaren Kachel-Desktop. Persistiert über Neustart (`aria_view_mode`).
- **Warum:** Nach dem 0.2.1.8-Deploy sah die App „unverändert" aus — korrekt, denn im normalen Chat gibt es nur eine Kachel (= Vollbild-Chat). Der Umschalter macht den Cockpit-Modus jetzt **explizit sichtbar/steuerbar**, statt nur bei Code-Projekten aufzutauchen.
- Im Cockpit ist die **Übersicht jetzt immer erreichbar** (auch im Hauptchat): „⤢ Übersicht" sitzt **im Header links** (kollidiert nicht mehr mit dem Abbrechen-Button der „ARIA denkt"-Leiste), dazu 2-Finger-Pinch/Pan und Hardware-Back.
- Im Cockpit werden **immer alle vier Kacheln** gezeigt (Chat/Editor/Desktop/Vorschau) — Editor/Desktop/Vorschau als Platzhalter mit Status-Untertitel („kein Code-Projekt" / „kein Desktop"), bis ARIA ein Code-Projekt startet bzw. eine VM läuft. Vorher wirkten sie „verschwunden", weil sie erst bei Code-Projekten auftauchten.
---
## [0.2.1.8] — 2026-07-17 — Desktop-Workspace: zoombarer Canvas, Live-Code-Editor, QEMU/VNC
### Hinzugefügt
**Zoom-/verschiebbarer Workspace-Canvas (App)**
- Die App ist jetzt eine desktop-artige Arbeitsfläche: rausgezoomt sieht man eine **Landkarte aus Kacheln** (Chat, Editor, Desktop, Vorschau), die man mit **2 Fingern zoomt und verschiebt**. Tippt man eine Kachel an, zoomt sie voll auf und wird **echt bedienbar** („Übersicht + Fokus"). „⤢ Übersicht" bzw. der Hardware-Back führen zurück zur Landkarte.
- Technisch: `react-native-gesture-handler` + `react-native-reanimated` (60 fps auf dem UI-Thread). Zwei Ebenen — eine skalierte Thumbnail-Welt und eine **Identity-Content-Ebene** (Scale 1), in der die schweren Inhalte (ChatScreen + WebViews) immer gemountet sind und nur die fokussierte sichtbar ist. Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet beim Fokuswechsel. **Reiner Chat verhält sich exakt wie bisher** (eine Kachel, dauerhaft fokussiert).
**Live-Code-Editor für Code-Projekte (App + Bridge + Proxy)**
- Wird ein Projekt zum Code-Projekt (ARIA ruft `set_project_kind('code')`), erscheinen Editor- und Desktop-Kachel. Der Editor (WebView, selbstenthaltener Highlight-Editor, offline) **zeigt live, was ARIA schreibt** — und Stefan kann selbst editieren; Änderungen gehen zurück an ARIA.
- Fluss: ARIAs `Write`/`Edit` unter `/shared/projects/<projekt-id>/` werden im Proxy abgefangen und als `code_file` über die Bridge/RVS an die App gespiegelt; Stefans Edits kommen als `code_file_edit` pfad-sicher zurück ins selbe Verzeichnis.
**QEMU für alle Architekturen + Live-Desktop per VNC (Host + Bridge + App)**
- ARIA kann jetzt VMs für **jede Architektur** bauen/testen (x86, ARM, MIPS, PPC, RISC-V, SPARC) — Host-Helper `aria-vm` (`create/boot/screenshot/list/stop`), installiert via `host-provisioning/qemu-setup.sh`. KVM für x86-Gäste, sonst TCG. Beispiel: ein Win-3.11-System bauen und in QEMU testen.
- Der **VNC-Live-Desktop wird durch den RVS-Server getunnelt**: die Bridge brückt rohes RFB-TCP (QEMU `127.0.0.1:5901`) ↔ RVS (`vnc_data`/`vnc_input`, Base64-in-JSON), der noVNC-Client läuft in der App-WebView (`window.WebSocket`-Shim). Stefan bedient die VM **live mit Maus/Tastatur** in der Desktop-Kachel — NAT-sicher, kein offener Port am Host, kein websockify/noVNC auf dem Host nötig.
**Kleineres**
- Pro-Projekt-Layout: die zuletzt fokussierte Kachel wird pro Projekt gemerkt (`aria_workspace_layout`).
- Projekt-Modell bekommt `kind` ('chat'|'code'); Seed-Regel lehrt ARIA den Code-Projekt-Workflow (Arbeitsverzeichnis `/shared/projects/<id>/`, `aria-vm`, VNC landet automatisch in der App).
### Deploy
`git pull && docker compose up -d --build brain bridge proxy` · RVS-Stack `up -d --build` · Host: `bash host-provisioning/qemu-setup.sh` (einmalig, als root) · APK neu bauen (nach `npm install` einmalig `npm start --reset-cache` + `gradlew clean`, wegen der neuen nativen Module).
---
## [0.2.1.5] — 2026-07-12 — Pro-Projekt-Queue mit Rückfrage-Loop
### Hinzugefügt
**Nachrichten-Queue pro Projekt (App + Diagnostic)**
- Eine zweite Nachricht, während ARIA am aktuellen Task arbeitet, wird jetzt **angestellt** statt den laufenden Task abzubrechen (vorher: Barge-In-Cancel). Sie läuft der Reihe nach, **pro Projekt unabhängig** (paralleles Arbeiten in mehreren Projekten bleibt). Wartende Nachrichten zeigen als ⏸-Bubble — tippen entfernt sie aus der Warteschlange.
- **Rückfrage-Loop:** Stellt ARIA eine echte, blockierende Rückfrage, **pausiert** die Queue und deine nächste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann läuft der nächste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". Der Stop-Button bricht den aktuellen Task ab und schaltet zum nächsten.
- ARIA signalisiert eine Rückfrage über einen **unsichtbaren `[[AWAIT]]`-Marker** — wie speak/converse deklariert das Modell den Zustand selbst (kein „endet-mit-?"-Raten). Brain strippt ihn, gibt `awaiting_reply` durch `chat()``ChatOut` → Bridge-Chat-Payload. Local (tool-los) und Fast-Path markieren nie.
**Pro-Projekt-Textfeld-Entwürfe (App + Diagnostic)**
- Der Feldinhalt bleibt beim Projektwechsel erhalten: in Projekt X tippen, zu Y wechseln (leeres Feld), zurück zu X → dein Entwurf steht wieder da. In Storage persistiert.
**TTS-Abspiel-Queue (App)**
- Zwei fast gleichzeitig fertige Antworten sprechen jetzt garantiert **nacheinander** statt sich gegenseitig abzuschneiden. Vorher war das Timing-Glück (`PcmStreamPlayer.start()` ruft `stopInternal()` = flush/release, hätte die laufende gecuttet). Jetzt: „spielt hörbar" gilt bis zum echten `PcmPlaybackFinished` (nicht nur bis Stream-Ende); eine neue hörbare Antwort, die währenddessen ankommt, wird gepuffert und danach nachgespielt (Kette für 3, 4, …). Harter Stop/Barge-In/Mund-Button verwirft die Queue.
### Geändert
- **Voice bricht nicht mehr ab:** eine neue Sprachnachricht während ARIA arbeitet stoppt nur akustisch das TTS (sauberes Mikro) und wird über den Brain-Projekt-Lock serialisiert, statt den laufenden Task abzubrechen (passend zu „immer anstellen + Stop-Button"). Text-Senden erkennt Brain-busy als Fallback, damit auch nach einem voice-gestarteten Turn korrekt angestellt wird. Grenze: eine per Sprache gestartete Aufgabe erscheint nicht als löschbare ⏸-Bubble (Aufnahme wird live gestreamt, nicht app-seitig gepuffert).
---
## [0.2.1.4] — 2026-07-12 — Lokales LLM: der ehrliche Rückbau
### Geändert
**Lokales LLM wieder tool-los (B1a) — ein 8B ist ein schlechter Tool-Caller**
- B1b hatte dem lokalen Modell Werkzeuge (`run_*`/`web_search`) gegeben — die gemeinsame Wurzel von **zwei** Problemen: (1) ein 8B erfindet mit Werkzeug in der Hand lieber eine plausible Antwort („der Song ist X") statt es zu rufen → Halluzination; (2) das erzwang per-Skill-Guards (skaliert nicht). Local ist jetzt wieder **tool-los** = reines Reden; alles mit Grundwahrheit (Fakt/Live-Zustand/Gedächtnis/Aktion) gehört an Claude oder den deterministischen Fast-Path. Kein Skill-Ergebnis mehr fälschbar
- **Keine Input-Wortliste im Router:** eine kurz eingeführte `_LIVE_HINTS`-Blacklist (Wetter/Musik/… → Claude) wieder entfernt — aus offenem Freitext die Absicht per Wortliste zu raten ist nie vollständig, jeder Miss = ein Halo (nur von per-Skill auf per-Wort verschoben). Generisch = das Modell entscheidet **selbst** (`<<ESCALATE>>`); ein stärkeres lokales Modell übernimmt die Selbst-Erkennung später, bis dahin ist local per Einstellung abschaltbar (aus, nicht raus)
- Expliziter Nutzer-Wunsch „nimm Claude/Clodi" wird im Router respektiert (geht nie lokal)
### Behoben
- **Info-Halluzination:** local nannte manchmal aktuellen Song/Restzeit/Skip-Titel ohne `run_spotify` zu rufen (mal echt, mal frei erfunden — „Midnight City von M83" nie aufgerufen). Neuer Output-Guard `_claims_live_media_state` eskaliert behauptete Live-Auskünfte ohne echten Skill-Call an Claude; Local-Prompt zusätzlich gehärtet (nie Titel/Zeit/Gerät ohne Tool-Ergebnis; bei „OK: next" keinen Titel erfinden)
- **Leeres `<voice></voice>` machte TTS stumm:** Claude hängt reflexartig manchmal ein leeres Voice-Tag an → `clean_text_for_tts` nahm den leeren Inhalt → gar keine Sprachausgabe (Playlist „Fliegen" gesprochen, „Prodigy" stumm — reiner Claude-Output-Zufall, nicht Skill/Playlist-Name). Leeres/whitespace-Tag wird jetzt ignoriert, der normale Anzeigetext gelesen
- **Datei-Anhang erschien erst nach Seitenwechsel:** die Live-`chat`-Payload trug keine `files` (Anhänge kamen nur als separates `file_from_aria`-Event) → an der Nachricht tauchte die Datei erst nach Reload aus `chat_backup` auf. Bridge schickt die `files` jetzt in der chat-Payload, App hängt sie live an die Text-Bubble (wie der Reload-Pfad) und entfernt die redundante Solo-Bubble
- **TDZ-Zeitbombe (App):** `sendTextMessage` stand vor seinen Dependencies (`interruptAriaIfBusy`, `sendPendingAttachments`) im deps-Array — Temporal Dead Zone; lief nur dank Babels `const``var`-Hebung, ein strengerer Bundler hätte beim Mount weißgescreent. Deklaration hinter die Deps verschoben
- **QRScanner tsc-clean:** toter Prop `colorForScannerFrame` (existiert in `react-native-camera-kit` v13 nicht) entfernt; die fehlerhaften Lib-Typen (optionale Props als required markiert) lokal + dokumentiert umgangen → **Projekt komplett tsc-clean (0 Fehler)**
**Spotify-Skill — von ARIA live im Gespräch weiter geschärft**
- Skip (`next`/`previous`) sagt jetzt den **echten** neuen Titel an (holt den Track nach dem Skip via API) statt local einen erfinden zu lassen
- `playlist_play`/`search_and_play`/`play` nennen das **tatsächliche** Wiedergabegerät (aus `GET /v1/me/player`, kein Raten — verhinderte den Fehler, dass Claude ein falsch geratenes Gerät auch noch ansteuerte) und lesen konsistent vor; saubere „Sprach-Grammatik": Ansagen sprechen, Steuerbefehle (play/pause/transfer/volume) schweigen
- `yt-dlp-download`-Skill um einen MP3-Modus erweitert — ARIA hat das fehlende Werkzeug **selbst gebaut**, als eine deutsche Titelmelodie nicht auf Spotify lag (Web-Suche → YouTube-Download → MP3 in den Chat, in <1 min)
---
## [0.2.1.3] — 2026-07-11
### Hinzugefügt
- **`skill_get`-Tool:** ARIA liest den echten Quellcode + Manifest + Readme eines Skills, **bevor** sie ihn ändert — kein Blind-Rewrite mehr (vorher wurde ein guter Skill durch eine schlechtere Neufassung ersetzt, weil das referenzierte `skill_get` gar nicht existierte)
### Behoben
- **`converse` folgt dem Skill (Fast-Path):** auch ein Fast-Path-Befehl kann einen Skill auslösen, nach dem noch etwas zu sagen ist — `converse` kommt jetzt aus Manifest/Skill-Output statt hart auf `False`
- **Sprachnachricht-Bubble verschwand nach manuellem Stop:** bei „ohne Ohr" aufgenommener Sprachnachricht + Stop entfernte ein leeres stream-end-Endpoint die schon gefüllte Bubble; jetzt wird nur noch der unaufgelöste Platzhalter entfernt
---
## [0.2.1.2] — 2026-07-11 — Standort-Intelligenz + Skill steuert seine Ausgabe
### Hinzugefügt
**GPS → Ortsname im Standort-Präfix (keyless, keine Tokens)**
- Reverse-Geocoding der Koordinaten in der Bridge (Nominatim zoom=18: Straße+Hausnr, PLZ+Ort, Bundesland; Straßen-Ref + Autobahn-km via Overpass) — damit das lokale Modell nicht „Berlin" für Oldenburg rät; Ortsname wird **vor** dem Präfix-Bau awaited (rechtzeitig für die erste Nachricht)
- Fahrtrichtung als Himmelsrichtung **+ exakte Peilung in Grad** (Haversine/Bearing aus aufeinanderfolgenden Fixes, `MIN_MOVE_M`-Schwelle gegen Zittern)
**Skill steuert seine Ausgabe selbst — `speak` + `converse` pro Aufruf**
- Ein Skill entscheidet per JSON-Output `{speak, converse}` pro Operation, ob vorgelesen wird und ob danach 30 s weitergelauscht wird (Manifest-Default, Output überschreibt) — z. B. „was läuft" vorlesen aber kein Dialog, „nächstes Lied" stumm. In der Skill-Bauanleitung **mit dem WARUM** dokumentiert, damit die KI die Flags beim Bauen versteht (kein Hardcode im Brain)
### Behoben / Geändert
- **Generischer Skill-Prompt (kein Hardcode):** der Router beschreibt `run_*`-Skills generisch (weiß nicht mehr, welche „schwer" sind); ein Stop im 30-s-Lauschen beendet dieses jetzt wirklich (kein zweiter Gong / erneutes Öffnen)
- **Anti-Halluzination:** behauptet local eine Steuerbefehl-Quittung („Spotify: …", „Playlist abspielen") ohne das Tool wirklich zu rufen → Eskalation an Claude statt erfundene Bestätigung durchzulassen
---
## [0.2.1.1] — 2026-07-11
### Hinzugefügt
- **Skill entscheidet selbst, ob vorgelesen wird (`manifest.speak`):** Grundlage der späteren `speak`/`converse`-Architektur — der `speak`-Flag greift sowohl im lokalen als auch im Claude-Pfad, statt am fragilen leeren `<voice></voice>`-Hack zu hängen
---
## [0.2.0.6] — 2026-07-11
### Hinzugefügt
**Diagnostic + App — Projekte verstecken**
- Neues `hidden`-Flag pro Projekt (bleibt voll nutzbar, nur aus Listen ausgeblendet — unabhängig von `status`/`archived`); `PATCH /projects/{id} {hidden}`
- Diagnostic: 👁-Auge pro Projekt-Bubble (🙈 verstecken / 👁 dauerhaft sichtbar), Header-Toggle „Versteckte anzeigen (N)" blendet sie temporär gedimmt + „versteckt"-Badge ein — zum Ansehen/Auswählen ohne permanentes Enttarnen
- App (`ProjectsBrowser`): versteckte standardmäßig ausgeblendet (Mama sieht sie nicht), Auge pro Zeile + Toggle spiegeln das Diagnostic-Verhalten; geteilter `hidden`-Status übers Brain
**Diagnostic — Token-Ersparnis durch lokales LLM**
- `metrics.jsonl` trägt jetzt `source` (claude | local | fast-path); lokale Calls nutzen echte `usage`-Tokens vom Adapter, Fast-Path = 0 Prompt-Tokens (`by_source`-Aggregation, rückwärts-kompatibel)
- Neue Card „Lokales LLM & Claude-Ersparnis": pro Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) + lokale Token-Last (eigene HW, kein Quota)
**Spotify-Skill — von ARIA selbst geschärft**
- Geräte-Transfer startet die Wiedergabe direkt mit (`play=true`) statt nur zu übertragen, inkl. Verifikation (`is_playing`-Check + expliziter Play-Fallback), Fuzzy-Gerätenamen und sauberen Exit-Codes; neue semantische Actions `play_on_device`/`search_and_play`/`playlist_play`/`queue_add`
### Behoben
- **Spotify-Resume (App):** nach einem Voice-Befehl blieb Spotify auf dem Handy pausiert. Statt des auf manchen Geräten (OnePlus) flakigen Audio-Focus-Nudge jetzt ein echter `KEYCODE_MEDIA_PLAY`-KeyEvent an die aktive MediaSession — gegated: nur wenn vor dem Dialog Musik lief (`isMusicActive`). Deterministisch, geräteunabhängig
- **TTS-Zahlen:** freistehende Ganzzahlen werden jetzt tag-unabhängig ausgeschrieben („23°C" → „dreiundzwanzig Grad Celsius", „100%" → „einhundert Prozent"). Regression, seit das lokale LLM (bewusst ohne `<voice>`-Tag) leichte Turns übernahm; neuer vollständiger Zahl→Wort-Konverter (0…999999) am Ende von `clean_text_for_tts`, lange Ziffernfolgen (IDs) bleiben Ziffern
- **„ARIA denkt" hängt:** Indikator + Abbrechen blieben stehen, obwohl der Turn laut Diagnostic fertig war. Die App räumt den kontext-scoped Indikator jetzt beim Eintreffen der Antwort selbst; die Bridge sendet zusätzlich ein `idle` für die Request-`projectId`, falls der Turn umgeroutet wurde (thinking ging mit Request-, idle mit Turn-`projectId`)
- **Lokale Tool-Fehler:** Action-Skills, die bei Exit 0 einen Fehlschlag nur im stdout-Text melden (Spotify: „Fehler beim Übertragen", „Gerät nicht gefunden"), eskalieren jetzt generisch an Claude statt vom lokalen LLM vorgelesen zu werden (Info-Tools wie web_search ausgenommen)
---
## [0.2.0.4 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
### Hinzugefügt
**Lokales LLM (Brain + Bridge + Adapter)**
- Router (B1a): Heuristik + `<<ESCALATE>>`-Selbstabbruch entscheidet pro Turn lokal vs. Claude; schlanker System-Prompt mit demselben `IDENTITY_ANCHOR` wie Claude (Rolle hält), nur letzte 8 Turns (Speed)
- Lokale Tool-Fast-Lane (B1b): kuratierte Tools — `web_search` (self-hosted **SearXNG**, local-only), `memory_search`, `trigger_timer`, Spotify; Eskalation bei Tool-Fehler statt Raten
- Consumer-Kette gespiegelt zu FLUX: Brain → Bridge `/internal/local-llm` → RVS → `llm-adapter` → llama.cpp; `enable_thinking:false` (Qwen wickelte sonst die ganze Antwort in `<think>`)
- **B0.5:** llama-swap (Hot-Swap der Modelle on-demand) + Modellauswahl-Dropdown + Live-Lade-Status (loading/ready + Download-Hinweis) in Diagnostic
- **SearXNG** als 6. Container auf der ARIA-VM (keyless Meta-Suche, JSON-API)
**Quell-Badge (local / claude / fast-path)**
- Diagnostic: immer an den ARIA-Bubbles
- App: optionaler Schalter in den Einstellungen, pro Gerät gemerkt, default aus („ich will's, meine Mama nicht")
**TTS — System-Flag `speak` (ja/nein) pro Antwort**
- Die Quelle entscheidet übers Vorlesen (Fast-Path/Steuerbefehl = stumm, ARIA-Antwort = vorlesen), robust statt des fragilen leeren `<voice></voice>`-Hacks der beim Skill-Rebuild verloren ging
### Behoben / Geändert
- **Identität (Hauptchat):** Proxy nutzt jetzt `--system-prompt` (voller Replace) statt `--append-system-prompt` — die Claude-Code-Basis-Identität leakt nicht mehr in den Hauptchat (ARIA antwortete dort als „Claude Code" bzw. deutete die Persona als Injection). Dazu `IDENTITY_SEED` (synthetischer Grounding-Turn) + Gift-Wächter (Identity-Breaks werden nie in die History persistiert, Retry+Fallback) + Cleanup-Script gegen bereits vergiftete Turns
- **Datenschutz (kritisch):** harte Diskretions-Regel im `IDENTITY_ANCHOR` — ARIA kennt intime/private Details, gibt sie aber **NIE ungefragt** preis (nicht in Vorstellungen, „was weißt du über mich", Zusammenfassungen, Triggern); nur auf konkrete Nachfrage, knapp. Bereits ausgeplauderte Turns bereinigt. Lokales Tier eskaliert Personen-/Beziehungs-/Gedächtnisfragen an Claude (kennt das Gedächtnis + antwortet diskret)
- **Lokale Antwort nicht in `<voice>`** wickeln (Qwen imitierte den Tag aus dem Kontext → Anzeige war leer); **generische** Tool-Fehler-Eskalation statt per-Skill-Router-Hardcode (Router muss nicht wissen, welche Skills „schwer" sind)
---
## [0.2.0.3] — 2026-07-10
### Hinzugefügt
**Proxy — ARIA-Persona über echten System-Prompt-Kanal**
- Persona + Tool-Use-Format gehen jetzt über `--append-system-prompt` der Claude-CLI statt als `<system>`-getaggter User-Content im Prompt (`openai-to-cli.js`: Prompt = nur Verlauf, `systemPrompt` separat; neue `sed`-Zeile schleust `--append-system-prompt`,`options.systemPrompt` ins `buildArgs`-Array von `manager.js`)
**Multi-Threading — echte Parallelität in der App**
- `agent_activity`-Events tragen jetzt die `projectId` (Brain → Proxy `aria_project_id` → Bridge → App); der „ARIA denkt"-Indikator zeigt nur noch den **fokussierten** Kontext statt global zu flackern (`agentActivityByCtx`-Map)
- Kontext-scoped Cancel: neuer Proxy-Endpoint `/cancel {projectId}` killt nur die Subprozesse *eines* Kontexts (`/cancel-all` bleibt fürs NOT-AUS); Bridge-soft-Cancel + App-Abbrechen tragen die fokussierte `projectId`
**Diagnostic — Datei-Zuordnung**
- Projekt-Dropdown pro Datei im Datei-Manager (nutzt `/api/files-set-project`) — auch alt-hochgeladene Dateien nachträglich einem Projekt zuweisen
### Behoben
- **Identität:** fester `IDENTITY_ANCHOR` ganz oben im System-Prompt — ARIA verliert in (Pentest-)Projekten nicht mehr die Rolle bzw. deutet ihre eigene Aufgabe nicht mehr als Prompt-Injection
- **Barge-In kontext-scoped:** eine Frage im Hauptchat blockiert/killt nicht mehr die parallele Arbeit in einem Projekt (Busy-Status kontextgenau aus `queueStatus` statt global)
---
## [0.1.9.7 0.2.0.2] — 2026-07-02 … 2026-07-10 — Projekte & Multi-Threading
Der große Epos: Themen-Bündel („Projekte") im Hauptchat, echt nebenläufig verarbeitet.
### Hinzugefügt
**Projekte (Brain + App + Diagnostic)**
- Named Themen-Bündel, im Hauptchat verankert, per Sprache adressierbar („steige in Projekt X ein", „für Frankreich: …"), CRUD via Meta-Tools + UI
- App: Focus-One-View + Drawer + Queue-Status-Dots + „← Hauptchat"-Button
- Diagnostic: Kontext-Strip + Focus-Filter + Queue-Polling
- Dateien pro Projekt getaggt (Manifest `file_projects.json`, Filter im Datei-Manager)
**Multi-Threading (Brain)**
- Per-Request `project_id` statt globalem `active_project`; per-Projekt-`asyncio.Lock` = Queue-Verhalten pro Kontext, verschiedene Kontexte laufen parallel
- Queue-Aware-Prompting (spätere Nachricht kann laufenden Task als überholt markieren) ohne Extra-LLM-Call
**Voice-Router (Bridge)**
- 30s-Sticky-Kontext, Prefix-Adressierung, Meta-Command-Interception („zurück zum Hauptchat" ohne Brain-Call), Voice folgt App-Focus
**Migration**
- Alt-getaggte Projekt-Nachrichten (in `conversation.jsonl`, aber ohne Tag im `chat_backup.jsonl`) werden nachträglich einsortiert — idempotent, nicht-destruktiv, reihenfolge-erhaltend
### Behoben
- **Leere Projekte:** Drawer resettete den App-Focus beim Öffnen auf `status.active` (im Multi-Threading = null); Diagnostic warf `project_id` beim `chat_history`-Reload weg (server.js + Renderer); untagged ARIA-Bubbles/Backup-Writes aus dem toten Gateway-Watch-Pfad
- **Voice → falscher Kontext:** Registry-Race (`stt_stream_end` poppte die Focus-`projectId` vor dem finalen `stt_endpoint`); App übernimmt jetzt die autoritative Server-`projectId` der STT-Bubble
- **STT-Endpointing:** akustische Stille als robustes Signal statt rein semantischer Stagnation (nicht mehr „hört nach zwei Worten auf" / „merkt Ende nicht")
- **Anhänge:** Bild/Datei + Frage landen im gewählten Projekt statt im Hauptchat (projectId durch die ganze Anhang-Kette)
- **Bild-Bubbles im Diagnostic:** ARIA-Datei-Bubbles tragen `project_id`, werden nicht mehr fälschlich vom Focus-Filter ausgeblendet
---
## [0.0.0.5] — 2026-03-13
Binary file not shown.
+631 -250
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
# ai-box — KI-Box (gpubox) Bootstrap
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
## Was das Script tut
1. Basis-Pakete (curl, gnupg, git …)
2. `contrib non-free non-free-firmware` aktivieren (Trixie-**deb822**-Format berücksichtigt)
3. **NVIDIA-Treiber** installieren (`nvidia-driver` + firmware)
4. **Docker** Engine + Compose-Plugin
5. **NVIDIA Container Toolkit** + Docker-Runtime auf NVIDIA konfigurieren
6. `xtts/.env` aus `.env.example` anlegen (RVS_TOKEN optional gleich setzen)
7. **GPU-im-Container-Test** (`docker run --gpus all … nvidia-smi`)
8. optional (`--up`): den `xtts`-Stack hochziehen
Alles **idempotent** — mehrfach ausführbar.
## Ablauf
```bash
git clone <repo> ARIA-AGENT
cd ARIA-AGENT/ai-box
sudo ./bootstrap.sh
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
sudo reboot
# … nach dem Boot:
cd ARIA-AGENT/ai-box
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
```
Der Treiber-Reboot ist normal (Kernel-Modul wird erst beim Boot geladen). Beim
zweiten Lauf überspringt das Script alles Erledigte und macht nur noch den
GPU-Test + Stack-Start.
## Optionen
| Option | Wirkung |
|---|---|
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
| `--rvs-host <H>` | `RVS_HOST` setzen |
## Wichtig
- **Stimm-Daten** (nicht in git): falls von der alten Box noch vorhanden,
`xtts/voice-id/` (Speaker-Fingerprint) + `xtts/voices/` (F5-Referenz) herkopieren.
Sonst egal — in der App neu anlegen: Stimme neu enrollen (ohne Fingerprint läuft
die Speaker-ID fail-open, alles geht durch) + F5-Voice-Referenz neu hochladen.
- **Voxtral bleibt aus** auf der 3060 (braucht ≥16 GB VRAM). Das Default-Profil
fährt Whisper (mit dem M0.1-Fix) + F5-TTS + lokales LLM. Voxtral erst mit der
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
## Verifizieren
```bash
nvidia-smi # Host sieht die GPU
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
```
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
#
# ARIA KI-Box (gpubox) Bootstrap — frisches Debian Trixie → startklarer
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
#
# Ablauf nach `git clone`:
# cd ARIA-AGENT/ai-box
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
#
# Optionen:
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
#
# IDEMPOTENT: bereits erledigte Schritte werden uebersprungen. Nach dem
# Treiber-Reboot einfach nochmal ausfuehren — der Rest laeuft dann durch.
#
set -euo pipefail
# ── CLI ──
DO_UP=0
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
RVS_HOST_ARG="${RVS_HOST:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--up) DO_UP=1; shift ;;
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1"; exit 1 ;;
esac
done
# ── Log-Helfer ──
c_g="\033[1;32m"; c_y="\033[1;33m"; c_r="\033[1;31m"; c_b="\033[1;34m"; c_0="\033[0m"
STEP=0
step() { STEP=$((STEP+1)); echo -e "\n${c_b}[${STEP}] $*${c_0}"; }
ok() { echo -e " ${c_g}${c_0} $*"; }
warn() { echo -e " ${c_y}!${c_0} $*"; }
die() { echo -e "${c_r}$*${c_0}" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
XTTS_DIR="$REPO_ROOT/xtts"
# ── Preflight ──
[[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausfuehren (sudo ./bootstrap.sh)."
command -v apt-get >/dev/null || die "Kein apt-get — dieses Script ist fuer Debian/Trixie."
export DEBIAN_FRONTEND=noninteractive
echo -e "${c_b}=== ARIA KI-Box Bootstrap ===${c_0}"
echo "Repo: $REPO_ROOT"
echo "xtts: $XTTS_DIR"
# ── 1. Basis-Pakete ──
step "Basis-Pakete"
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg git lsb-release >/dev/null
ok "ca-certificates, curl, gnupg, git"
# ── 2. non-free Repos aktivieren (Trixie deb822 + Legacy) ──
step "APT-Komponenten (contrib non-free non-free-firmware)"
# Ergaenzt die drei Komponenten in JEDER Components:-Zeile einer deb822-Datei —
# pro Zeile nur was fehlt, reihenfolge-robust, idempotent. Die Adressen pruefen
# ganze Woerter (non-free-firmware zaehlt NICHT als non-free).
add_components() {
sed -i -E '/^[Cc]omponents:/{
/(^|[[:space:]])contrib([[:space:]]|$)/!s/$/ contrib/
/(^|[[:space:]])non-free([[:space:]]|$)/!s/$/ non-free/
/(^|[[:space:]])non-free-firmware([[:space:]]|$)/!s/$/ non-free-firmware/
}' "$1"
}
ENABLED_ANY=0
shopt -s nullglob
for f in /etc/apt/sources.list.d/*.sources; do
grep -qE '^[Cc]omponents:' "$f" || continue
b="$(md5sum "$f")"; add_components "$f"; a="$(md5sum "$f")"
if [[ "$b" != "$a" ]]; then ENABLED_ANY=1; ok "aktualisiert: $(basename "$f")"; fi
done
shopt -u nullglob
# Legacy /etc/apt/sources.list (deb-Zeilen)
if [[ -f /etc/apt/sources.list ]] && grep -qE '^deb ' /etc/apt/sources.list; then
if ! grep -qE '^deb .*[[:space:]]non-free([[:space:]]|$)' /etc/apt/sources.list; then
sed -i -E '/^deb .*debian/ s/$/ contrib non-free non-free-firmware/' /etc/apt/sources.list
ENABLED_ANY=1; ok "aktualisiert: sources.list"
fi
fi
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
# ── 3. NVIDIA-Treiber ──
step "NVIDIA-Treiber"
if nvidia-smi >/dev/null 2>&1; then
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
DRIVER_ACTIVE=1
else
if dpkg -l | grep -q '^ii nvidia-driver '; then
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
DRIVER_ACTIVE=0
else
# KEIN separater Kandidaten-Check — die apt-cache-Ausgabe ist locale-/pipe-
# fragil (hat faelschlich "kein Kandidat" gemeldet). Der Install IST der Test:
# direkt installieren; schlaegt er fehl, einmal volles apt-get update + Retry,
# dann erst mit Diagnose abbrechen.
# Kernel-Header ZUERST — sonst ueberspringt DKMS den Modulbau ("No kernel
# headers were found") und nvidia-smi kann spaeter nicht mit dem Treiber reden.
warn "Kernel-Header + nvidia-driver installieren (DKMS-Build, dauert)…"
apt-get install -y linux-headers-amd64 || true
apt-get install -y "linux-headers-$(uname -r)" || true
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
warn "Install fehlgeschlagen — volles 'apt-get update' + zweiter Versuch…"
apt-get update
if ! apt-get install -y nvidia-driver firmware-misc-nonfree; then
echo
warn "nvidia-driver liess sich nicht installieren. Aktive Quellen:"
grep -rhE '^[Cc]omponents:' /etc/apt/sources.list.d/*.sources 2>/dev/null | sed 's/^/ /' || true
grep -E '^deb ' /etc/apt/sources.list 2>/dev/null | sed 's/^/ /' || true
die "Pruefe 'apt-cache policy nvidia-driver' + Netz/non-free."
fi
fi
# Falls nvidia-kernel-dkms schon (ohne Header) installiert war: Modul jetzt bauen.
dkms autoinstall >/dev/null 2>&1 || true
ok "nvidia-driver + Kernel-Modul installiert"
DRIVER_ACTIVE=0
fi
fi
# ── 4. Docker Engine + Compose-Plugin ──
step "Docker"
if command -v docker >/dev/null 2>&1; then
ok "Docker vorhanden: $(docker --version)"
else
warn "Installiere Docker (offizielles get.docker.com)…"
curl -fsSL https://get.docker.com | sh >/dev/null
systemctl enable --now docker >/dev/null 2>&1 || true
ok "Docker installiert: $(docker --version)"
fi
if docker compose version >/dev/null 2>&1; then
ok "Compose-Plugin: $(docker compose version | head -1)"
else
warn "Compose-Plugin fehlt — installiere docker-compose-plugin…"
apt-get install -y -qq docker-compose-plugin >/dev/null || \
warn "Konnte docker-compose-plugin nicht via apt holen — get.docker.com bringt es normalerweise mit."
fi
# ── 5. NVIDIA Container Toolkit ──
step "NVIDIA Container Toolkit"
NCT_LIST="/etc/apt/sources.list.d/nvidia-container-toolkit.list"
NCT_KEY="/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg"
if ! dpkg -l | grep -q '^ii nvidia-container-toolkit '; then
[[ -f "$NCT_KEY" ]] || curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o "$NCT_KEY"
if [[ ! -f "$NCT_LIST" ]]; then
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed "s#deb https://#deb [signed-by=${NCT_KEY}] https://#g" > "$NCT_LIST"
fi
apt-get update -qq
apt-get install -y -qq nvidia-container-toolkit >/dev/null
ok "nvidia-container-toolkit installiert"
else
ok "nvidia-container-toolkit vorhanden"
fi
# Docker-Runtime auf NVIDIA konfigurieren (idempotent)
if ! grep -q '"nvidia"' /etc/docker/daemon.json 2>/dev/null; then
nvidia-ctk runtime configure --runtime=docker >/dev/null
systemctl restart docker
ok "Docker-Runtime auf NVIDIA konfiguriert + Docker neugestartet"
else
ok "Docker-Runtime bereits NVIDIA-konfiguriert"
fi
# ── 6. xtts/.env vorbereiten ──
step "xtts/.env"
if [[ -f "$XTTS_DIR/.env" ]]; then
ok ".env existiert bereits (unangetastet)"
else
cp "$XTTS_DIR/.env.example" "$XTTS_DIR/.env"
ok ".env aus .env.example erstellt"
fi
if [[ -n "$RVS_TOKEN_ARG" ]]; then
sed -i -E "s#^RVS_TOKEN=.*#RVS_TOKEN=${RVS_TOKEN_ARG}#" "$XTTS_DIR/.env"
ok "RVS_TOKEN eingetragen"
fi
if [[ -n "$RVS_HOST_ARG" ]]; then
sed -i -E "s#^RVS_HOST=.*#RVS_HOST=${RVS_HOST_ARG}#" "$XTTS_DIR/.env"
ok "RVS_HOST=${RVS_HOST_ARG} eingetragen"
fi
if grep -q '^RVS_TOKEN=dein_token_hier' "$XTTS_DIR/.env"; then
warn "RVS_TOKEN ist noch der Platzhalter — vor dem Start setzen:"
warn " nano $XTTS_DIR/.env (oder: sudo ./bootstrap.sh --token <TOKEN>)"
fi
warn "Stimm-Daten (nicht in git): falls von der alten Box noch vorhanden, xtts/voice-id/"
warn " + xtts/voices/ herkopieren. Sonst egal — in der App neu anlegen:"
warn " Sprache neu enrollen (Speaker-ID, sonst fail-open) + F5-Referenz neu hochladen."
# ── 7. GPU-im-Container verifizieren ──
step "GPU-im-Container Test"
if [[ "${DRIVER_ACTIVE:-0}" -eq 1 ]]; then
if docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi >/dev/null 2>&1; then
ok "Docker sieht die GPU — KI-Box ist einsatzbereit."
GPU_READY=1
else
warn "Host-Treiber ok, aber Container sieht die GPU nicht — Toolkit/Runtime pruefen."
GPU_READY=0
fi
else
warn "Treiber noch nicht aktiv → Test uebersprungen."
echo
echo -e "${c_y}==> REBOOT noetig, dann Script erneut ausfuehren:${c_0}"
echo -e "${c_y} sudo reboot && (nach dem Boot) sudo ./bootstrap.sh${c_0}"
exit 0
fi
# ── 8. Optional: xtts-Stack hochziehen ──
if [[ $DO_UP -eq 1 && "${GPU_READY:-0}" -eq 1 ]]; then
step "xtts-Stack starten (Default-Profil: Whisper + F5 + LLM — OHNE voxtral)"
warn "Erster Start laedt Modelle (mehrere GB via HuggingFace) — kann dauern."
( cd "$XTTS_DIR" && docker compose up -d --build )
ok "Stack laeuft. Logs: docker logs -f aria-whisper-bridge"
echo
echo " voxtral (STT via Voxtral) braucht >=16 GB VRAM → erst mit der 24-GB-Karte:"
echo " cd $XTTS_DIR && docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build"
fi
# ── Abschluss ──
echo
echo -e "${c_g}=== Fertig. KI-Box startklar. ===${c_0}"
if [[ $DO_UP -eq 0 ]]; then
echo "Naechster Schritt — Stack starten:"
echo " cd $XTTS_DIR && docker compose up -d --build"
echo "oder direkt: sudo ./bootstrap.sh --up"
fi
+83 -5
View File
@@ -6,13 +6,19 @@
*/
import React, { useEffect } from 'react';
import { StatusBar, StyleSheet } from 'react-native';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
import gpsTrackingService from './src/services/gpsTracking';
// --- Navigation ---
@@ -44,6 +50,13 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
const App: React.FC = () => {
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
useEffect(() => {
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
// 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) {
@@ -53,14 +66,75 @@ const App: React.FC = () => {
};
initConnection();
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
// durch weil Android nach ~30s die JS-Engine pausiert.
//
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
const initBackground = async () => {
const setting = await AsyncStorage.getItem('aria_background_mode');
if (setting === 'false') {
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
return;
}
// Permission fuer die persistente Notification
if (Platform.OS === 'android' && Platform.Version >= 33) {
try {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
} catch {}
}
try {
await acquireBackgroundAudio('background');
console.log('[App] Hintergrund-Modus aktiv');
} catch (err: any) {
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
}
};
initBackground();
// GPS-Tracking-Status aus AsyncStorage wiederherstellen (war
// bisher nur an SettingsScreen-Mount gekoppelt; wenn Stefan
// direkt im Chat startete blieb GPS aus bis er Settings oeffnete).
gpsTrackingService.restoreFromStorage().catch((err) => {
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
});
// AppState-Listener: nach Hintergrund-Rueckkehr aktiv die WS-
// Verbindung neu aufbauen. Hintergrund: Android kann den TCP-Socket
// im Background killen, JS-State zeigt aber noch OPEN → Stefan musste
// manuell in Settings auf "Verbinden" tippen, oft mehrfach. Mit dem
// force-Reconnect bei "active" greift das automatisch.
let lastAppState: AppStateStatus = AppState.currentState;
const appStateSub = AppState.addEventListener('change', (next) => {
const wasBg = lastAppState !== 'active';
lastAppState = next;
if (next === 'active' && wasBg) {
console.log('[App] Foreground-Resume — force-reconnect zum RVS');
try { rvs.connect(true); } catch (e: any) {
console.warn('[App] force-reconnect fehlgeschlagen:', e?.message || e);
}
}
});
// Beim Beenden: Verbindung sauber trennen
return () => {
appStateSub.remove();
rvs.disconnect();
};
}, []);
return (
<>
<GestureHandlerRootView style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
@@ -93,10 +167,11 @@ const App: React.FC = () => {
>
<Tab.Screen
name="Chat"
component={ChatScreen}
component={WorkspaceScreen}
options={{
title: 'ARIA Chat',
headerTitle: 'ARIA Cockpit',
headerRight: () => <ViewModeToggle />,
}}
/>
<Tab.Screen
@@ -108,13 +183,16 @@ const App: React.FC = () => {
/>
</Tab.Navigator>
</NavigationContainer>
</>
</GestureHandlerRootView>
);
};
// --- Styles ---
const styles = StyleSheet.create({
root: {
flex: 1,
},
header: {
backgroundColor: '#12122A',
elevation: 0,
+18 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 303
versionName "0.0.3.3"
versionCode 20302
versionName "0.2.3.2"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -104,6 +104,19 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
// APK von ~136 MB auf ~35 MB — relevant weil ONNX Runtime + die anderen
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
splits {
abi {
enable true
reset()
include "arm64-v8a"
universalApk false
}
}
}
dependencies {
@@ -111,6 +124,9 @@ dependencies {
implementation("com.facebook.react:react-android")
implementation("com.facebook.react:flipper-integration")
// ONNX Runtime fuer on-device Wake-Word (openWakeWord ONNX-Modelle in assets/openwakeword/)
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.1")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
@@ -4,6 +4,31 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Background-Location ist OPT-IN (Settings → GPS auch im Hintergrund).
Muss vom User explizit in Android-Einstellungen auf "Immer erlauben"
gesetzt werden — kann nicht ueber den normalen Permission-Dialog
angefordert werden (Android 10+). Default: aus. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
Aufnahme im Gespraechsmodus). LOCATION wird nur aktiv wenn der
User Background-GPS in Settings einschaltet. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
laeuft weiter aber JS-Bridge frisst die DeviceEvents nicht mehr ->
Wake-Word wird erkannt aber callbacks feuern erst beim App-Resume. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".MainApplication"
@@ -35,5 +60,10 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".AriaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone|location" />
</application>
</manifest>
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
class ApkInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ApkInstallerModule(reactContext))
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -0,0 +1,147 @@
package com.ariacockpit
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
/**
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
* und ARIA verstummt mitten im Satz.
*
* Notification ist persistent (ongoing) waehrend der Service laeuft.
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
*
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
* wirft startForeground() eine SecurityException.
*/
class AriaPlaybackService : Service() {
companion object {
private const val TAG = "AriaPlaybackService"
private const val CHANNEL_ID = "aria_playback"
private const val NOTIFICATION_ID = 1042
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
}
private var currentReason: String = ""
// PARTIAL_WAKE_LOCK haelt die CPU wach solange der Foreground-Service
// aktiv ist. Damit bleibt die JS-Bridge im Doze ansprechbar und die
// gesamte Sprach-Pipeline (Wake → Aufnahme → POST → ARIA → TTS → wieder
// Wake) laeuft durchgehend im Hintergrund. Ein einziger Lock fuer den
// ganzen Foreground-Cycle, nicht pro Sub-Modul.
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
acquireWakeLock()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
currentReason = reason
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
// Falls der Lock zwischendurch released wurde (z.B. nach onCreate-
// race oder OS-quirk), hier sicherheits-halber erneut anfordern.
acquireWakeLock()
try {
startForeground(NOTIFICATION_ID, buildNotification(reason))
} catch (e: Exception) {
Log.e(TAG, "startForeground fehlgeschlagen", e)
stopSelf()
}
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
// wieder starten — die App entscheidet wann der Service noetig ist.
return START_NOT_STICKY
}
override fun onDestroy() {
releaseWakeLock()
Log.i(TAG, "Foreground-Service gestoppt")
super.onDestroy()
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
try {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:Pipeline").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L) // 8h Sicherheits-Cap
}
Log.i(TAG, "WakeLock acquired (CPU bleibt wach im Hintergrund)")
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
}
private fun releaseWakeLock() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
if (wakeLock != null) Log.i(TAG, "WakeLock released")
} catch (e: Exception) {
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
}
wakeLock = null
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"ARIA Audio-Wiedergabe",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
}
}
private fun buildNotification(reason: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else
PendingIntent.FLAG_UPDATE_CURRENT
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
val (title, body) = when (reason) {
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
}
@@ -0,0 +1,314 @@
package com.ariacockpit
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
/**
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events
* an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert
* zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern
* requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen.
*
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
* - release() → Focus abgeben, andere Apps duerfen wieder
*
* Events:
* - "AudioFocusChanged" mit type:
* "loss" — endgueltiger Verlust (Anruf, andere App permanent)
* "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung)
* "gain" — Fokus zurueck
*/
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "AudioFocus"
companion object { private const val TAG = "AudioFocus" }
private var currentRequest: AudioFocusRequest? = null
private fun audioManager(): AudioManager? =
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private fun emitFocusChange(type: String) {
try {
val params = Arguments.createMap().apply { putString("type", type) }
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("AudioFocusChanged", params)
} catch (e: Exception) {
Log.w(TAG, "emit failed: ${e.message}")
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)")
emitFocusChange("loss")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)")
emitFocusChange("loss_transient")
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter
Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert")
}
AudioManager.AUDIOFOCUS_GAIN -> {
Log.i(TAG, "AUDIOFOCUS_GAIN")
emitFocusChange("gain")
}
}
}
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
val am = audioManager()
if (am == null) {
promise.reject("NO_AUDIO_MANAGER", "AudioManager nicht verfuegbar")
return
}
release()
val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(usage)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val req = AudioFocusRequest.Builder(durationHint)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(focusListener)
.build()
currentRequest = req
am.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
}
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
}
/** Andere Apps werden pausiert (TTS spricht).
*
* TRANSIENT (statt TRANSIENT_MAY_DUCK): Spotify/YouTube pausieren komplett
* statt nur leiser zu werden. Verhindert auch das "kommt-wieder-hoch"-
* Problem mit MAY_DUCK, wo das System nach kurzer Zeit den Duck-Effekt
* wieder aufgehoben hat obwohl wir den Fokus noch hielten.
*/
@ReactMethod
fun requestDuck(promise: Promise) {
requestFocus(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
AudioAttributes.USAGE_ASSISTANT,
promise,
)
}
/** Andere Apps werden pausiert (Mikrofon-Aufnahme / Gespraech). */
@ReactMethod
fun requestExclusive(promise: Promise) {
requestFocus(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
AudioAttributes.USAGE_VOICE_COMMUNICATION,
promise,
)
}
/** Focus abgeben — andere Apps duerfen wieder volle Lautstaerke. */
@ReactMethod
fun release(promise: Promise) {
release()
promise.resolve(true)
}
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
* requesten und sofort abandonen. Spotify bekommt das als
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
* "user stopped" was Auto-Resume verhindert haette.
*
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
* der alten kickReleaseMedia mit GAIN permanent.
*/
@ReactMethod
fun nudgeMediaResume(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
Thread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(nudgeListener)
.build()
am.requestAudioFocus(nudgeReq)
Thread.sleep(100)
am.abandonAudioFocusRequest(nudgeReq)
} else {
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
Thread.sleep(100)
@Suppress("DEPRECATION")
am.abandonAudioFocus(nudgeListener)
}
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
} catch (e: Exception) {
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
/** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die
* aktive MediaSession schicken — exakt das Signal der Play-Taste am
* Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick,
* auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys
* MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder.
*
* Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur
* starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn
* vor dem Gespraech wirklich Musik lief, siehe isMusicActive()).
*/
@ReactMethod
fun dispatchMediaPlay(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
try {
val now = SystemClock.uptimeMillis()
val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
am.dispatchMediaKeyEvent(down)
am.dispatchMediaKeyEvent(up)
Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet")
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "dispatchMediaPlay failed: ${e.message}")
promise.resolve(false)
}
}
/** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive).
* Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu
* entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */
@ReactMethod
fun isMusicActive(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
promise.resolve(am.isMusicActive)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
* bekommt den Focus-Gain und resumed.
*
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
* laesst den AudioFocusRequest haengen.
*
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
* "user-action stopp" interpretieren und Auto-Resume verhindern.
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
*/
@ReactMethod
fun kickReleaseMedia(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
// Focus-Stack-Refresh.
Thread {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setOnAudioFocusChangeListener(kickListener)
.build()
am.requestAudioFocus(kickReq)
Thread.sleep(250)
am.abandonAudioFocusRequest(kickReq)
} else {
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
@Suppress("DEPRECATION")
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
Thread.sleep(250)
@Suppress("DEPRECATION")
am.abandonAudioFocus(kickListener)
}
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
} catch (e: Exception) {
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
}
}.start()
promise.resolve(true)
}
private fun release() {
val am = audioManager() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
currentRequest?.let { am.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
am.abandonAudioFocus(focusListener)
}
currentRequest = null
}
/** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4.
* IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) —
* kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */
@ReactMethod
fun getMode(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(0)
return
}
promise.resolve(am.mode)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class AudioFocusPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(AudioFocusModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,59 @@
package com.ariacockpit
import android.content.Intent
import android.os.Build
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
/**
* RN-Bridge fuer den AriaPlaybackService.
*
* Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den
* App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht
* weiter, auch wenn Stefan die App minimiert hat).
*
* Service stoppt entweder explizit per stop() oder wird von Android
* mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur
* passiert wenn der User die App force-stopped).
*/
class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "BackgroundAudio"
companion object { private const val TAG = "BackgroundAudio" }
@ReactMethod
fun start(reason: String, promise: Promise) {
try {
val ctx = reactApplicationContext
val intent = Intent(ctx, AriaPlaybackService::class.java)
intent.putExtra(AriaPlaybackService.EXTRA_REASON, reason ?: "")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(intent)
} else {
ctx.startService(intent)
}
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "start fehlgeschlagen: ${e.message}")
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val ctx = reactApplicationContext
ctx.stopService(Intent(ctx, AriaPlaybackService::class.java))
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "stop fehlgeschlagen: ${e.message}")
promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class BackgroundAudioPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(BackgroundAudioModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,55 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
/**
* Oeffnet eine beliebige Datei (PDF, Bild, Office-Doc, ...) mit der vom User
* gewaehlten App via Android-Intent-Picker. Nutzt FileProvider damit auch
* Android 7+ (content:// statt file://) das URI lesen darf.
*
* MIME-Type wird vom Caller bestimmt — App-Auswahl ist davon abhaengig (PDF
* geht an PDF-Viewer, image/jpeg an Galerie, etc.).
*/
class FileOpenerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "FileOpener"
@ReactMethod
fun open(filePath: String, mimeType: String, promise: Promise) {
try {
val cleanPath = filePath.removePrefix("file://")
val file = File(cleanPath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "Datei nicht gefunden: $cleanPath")
return
}
val context = reactApplicationContext
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
} else {
Uri.fromFile(file)
}
val safeMime = if (mimeType.isBlank()) "application/octet-stream" else mimeType
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, safeMime)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Chooser zeigt Android-Auswahl falls mehrere Apps das MIME oeffnen koennen.
val chooser = Intent.createChooser(intent, "Oeffnen mit").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(chooser)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("OPEN_ERROR", e.message, e)
}
}
}
@@ -19,6 +19,12 @@ class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(ApkInstallerPackage())
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(PcmStreamRecorderPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,574 @@
package com.ariacockpit
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioRecordingConfiguration
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.nio.FloatBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord).
*
* Drei-stufige ONNX Pipeline:
* 1. Audio (16kHz mono int16, 1280-Sample-Chunks) → Melspectrogram → 32-mel Frames
* 2. 76 Mel-Frames Sliding Window (stride 8) → Speech-Embedding → 96-dim Vektor
* 3. Letzte 16 Embeddings (~1.28s Kontext) → Wake-Word-Klassifikator → Sigmoid-Score
*
* Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword
* ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden
* Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`.
*
* Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde.
*/
class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "OpenWakeWord"
companion object {
private const val TAG = "OpenWakeWord"
private const val SAMPLE_RATE = 16000
private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
private const val EMBEDDING_DIM = 96
private const val MEL_BINS = 32
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
// Nach record.startRecording() erzeugt das Mikro fuer ~1s einen Spin-up-Spike
// (DC-Offset, AGC-Settling) der vom Wake-Word-Klassifikator faelschlich als
// Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik,
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 1500L
}
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
private var melSession: OrtSession? = null
private var embSession: OrtSession? = null
private var wwSession: OrtSession? = null
private var melInputName: String = "input"
private var embInputName: String = "input_1"
private var wwInputName: String = "input"
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
// Wird beim init() aus den Modell-Metadaten gelesen.
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
// Konfiguration
private var threshold: Float = 0.5f
private var patience: Int = 2
private var debounceMs: Long = 1500
private var modelName: String = "hey_jarvis"
// Audio-Capture-Thread
private var audioRecord: AudioRecord? = null
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
// Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
// das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
// Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
// die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
// queuen sich die Events nur und werden erst beim App-Foreground
// delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
// kommen erst wenn ich die App nach vorne hole").
private var wakeLock: PowerManager.WakeLock? = null
// Inferenz-State
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
private var melProcessedIdx: Int = 0
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L
// Audio-Sharing mit anderen Apps:
// Wenn z.B. WhatsApp eine Sprachnachricht aufnimmt, dann hält ARIAs
// VOICE_COMMUNICATION-Lock zwar das System nicht offiziell exklusiv,
// aber die Foreground-App bekommt nur Stille — die WhatsApp-Aufnahme
// ist tonlos. Loesung: AudioRecordingCallback hoeren, sobald eine andere
// App das Mic anfordert → unsere AudioRecord freigeben (externallyPaused=true).
// Wenn die andere App fertig ist → reaktivieren. Wakeword pausiert solange.
private var recordingCallback: AudioManager.AudioRecordingCallback? = null
@Volatile private var externallyPaused: Boolean = false
private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) }
private val audioManager: AudioManager by lazy {
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
/**
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
*/
@ReactMethod
fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
try {
disposeSessions()
this.modelName = modelName
this.threshold = threshold.toFloat()
this.patience = patience.coerceAtLeast(1)
this.debounceMs = debounceMs.toLong()
val ctx = reactApplicationContext
val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }
val opts = OrtSession.SessionOptions()
melSession = env.createSession(melBytes, opts)
embSession = env.createSession(embBytes, opts)
wwSession = env.createSession(wwBytes, opts)
melInputName = melSession!!.inputNames.first()
embInputName = embSession!!.inputNames.first()
wwInputName = wwSession!!.inputNames.first()
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
disposeSessions()
promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun start(promise: Promise) {
if (running.get()) {
promise.resolve(true)
return
}
if (melSession == null || embSession == null || wwSession == null) {
promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
return
}
// Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
// aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
// failt.
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
if (perm != PackageManager.PERMISSION_GRANTED) {
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
return
}
try {
acquireAndStartRecording()
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
// 8h Cap als Sicherheit gegen forgotten-release.
try {
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:WakeWordRecord").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L)
}
Log.i(TAG, "WakeLock acquired")
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
// AudioRecordingCallback registrieren: andere Apps (WhatsApp-
// Sprachnachricht, Telefonate etc.) wollen das Mic — wir geben
// es vorruebergehend frei statt sie ins Leere recorden zu lassen.
registerRecordingCallback()
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
running.set(false)
audioRecord?.release()
audioRecord = null
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
/** Reine AudioRecord + Effects + Capture-Thread-Acquisition. Wirft bei
* Fehler — Caller faengt + reportet. Kein WakeLock, keine Callbacks. */
private fun acquireAndStartRecording() {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
// zur TTS-Wiedergabe gelauscht wird.
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
throw IllegalStateException("AudioRecord nicht initialisiert (Mikro belegt?)")
}
audioRecord = record
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
resetInferenceState()
running.set(true)
record.startRecording()
recordingStartedMs = System.currentTimeMillis()
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
}
}
/** Reine AudioRecord + Effects + Capture-Thread-Release. Sicher (catch all).
* Kein WakeLock-Release, kein Unregistrieren der Callbacks. */
private fun stopAndReleaseRecording() {
running.set(false)
try { captureThread?.join(1500) } catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
@ReactMethod
fun stop(promise: Promise) {
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@ReactMethod
fun dispose(promise: Promise) {
unregisterRecordingCallback()
externallyPaused = false
stopAndReleaseRecording()
releaseWakeLock()
disposeSessions()
promise.resolve(true)
}
// ── External-Mic-Sharing (AudioRecordingCallback) ──────────────────────
//
// Wenn eine andere App das Mic anfordert (WhatsApp-Voicenote, Telefonie,
// Sprach-Suche im Browser etc.), kriegt die zwar formal Audio — aber
// unsere VOICE_COMMUNICATION-Pipeline blockiert die naively neue Aufnahme
// mit Stille (Android-Audio-Policy). Loesung: AudioRecordingCallback
// beobachten, andere Recorder-Sessions detecten, und unsere Pipeline
// temporaer freigeben. Sobald die andere App fertig ist → reaktivieren.
//
// Effekt: Wake-Word funktioniert solange nicht — fairer Kompromiss.
private fun registerRecordingCallback() {
if (recordingCallback != null) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Log.i(TAG, "AudioRecordingCallback nicht verfuegbar (API < 24) — Mic-Sharing inaktiv")
return
}
val cb = object : AudioManager.AudioRecordingCallback() {
override fun onRecordingConfigChanged(configs: MutableList<AudioRecordingConfiguration>?) {
handleRecordingConfigChange(configs)
}
}
try {
audioManager.registerAudioRecordingCallback(cb, mainHandler)
recordingCallback = cb
Log.i(TAG, "AudioRecordingCallback registriert — beobachtet andere Mic-User")
} catch (e: Exception) {
Log.w(TAG, "registerAudioRecordingCallback failed: ${e.message}")
}
}
private fun unregisterRecordingCallback() {
val cb = recordingCallback ?: return
try { audioManager.unregisterAudioRecordingCallback(cb) } catch (_: Exception) {}
recordingCallback = null
}
private fun handleRecordingConfigChange(configs: MutableList<AudioRecordingConfiguration>?) {
if (configs == null) return
// Unsere eigene Session anhand der audioSessionId filtern. Wenn wir
// gerade keinen AudioRecord halten (externallyPaused), ist alles
// andere "extern" — dann zaehlt jeder Eintrag.
val ourSessionId = audioRecord?.audioSessionId
val externalActive = configs.any {
ourSessionId == null || it.clientAudioSessionId != ourSessionId
}
if (running.get() && externalActive) {
Log.i(TAG, "Andere App nutzt Mic — Wake-Word pausiert (configs=${configs.size})")
externallyPaused = true
stopAndReleaseRecording()
return
}
if (externallyPaused && !externalActive) {
Log.i(TAG, "Mic wieder frei — Wake-Word reaktiviert in 300ms")
// Kurze Pause: der "andere" hat eben losgelassen, Audio-Stack braucht
// ein paar ms bis VOICE_COMMUNICATION wieder sauber initialisiert.
mainHandler.postDelayed({
if (!externallyPaused) return@postDelayed // schon resumed
// Sicherheitscheck: wenn inzwischen jemand wieder rein ist
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val cur = audioManager.activeRecordingConfigurations
if (cur != null && cur.isNotEmpty()) {
Log.i(TAG, "Resume verworfen — anderer Mic-User noch da (${cur.size})")
return@postDelayed
}
}
externallyPaused = false
try {
acquireAndStartRecording()
Log.i(TAG, "Wake-Word nach External-Pause reaktiviert")
} catch (e: Exception) {
Log.w(TAG, "Resume nach External-Pause failed: ${e.message}")
// bleiben unten — falls anderer App das Mic doch wieder
// freigibt, feuert der Callback erneut.
externallyPaused = true
}
}, 300L)
}
}
private fun releaseWakeLock() {
try {
wakeLock?.takeIf { it.isHeld }?.release()
if (wakeLock != null) Log.i(TAG, "WakeLock released")
} catch (e: Exception) {
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
}
wakeLock = null
}
@ReactMethod
fun isAvailable(promise: Promise) {
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
promise.resolve(true)
}
// RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun disposeSessions() {
try { melSession?.close() } catch (_: Exception) {}
try { embSession?.close() } catch (_: Exception) {}
try { wwSession?.close() } catch (_: Exception) {}
melSession = null
embSession = null
wwSession = null
}
private fun resetInferenceState() {
melBuffer.clear()
melProcessedIdx = 0
embBuffer.clear()
consecutiveAboveThreshold = 0
lastDetectionMs = 0L
}
private fun emitDetected() {
val sinceStart = System.currentTimeMillis() - recordingStartedMs
if (sinceStart in 0 until STARTUP_SUPPRESSION_MS) {
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return
}
val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName)
}
try {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("WakeWordDetected", params)
} catch (e: Exception) {
Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
}
}
private fun captureLoop() {
val buf = ShortArray(CHUNK_SAMPLES)
val record = audioRecord ?: return
Log.i(TAG, "Capture-Loop gestartet")
while (running.get()) {
var read = 0
while (read < CHUNK_SAMPLES && running.get()) {
val n = record.read(buf, read, CHUNK_SAMPLES - read)
if (n <= 0) {
Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
running.set(false)
return
}
read += n
}
if (!running.get()) break
try {
processChunk(buf)
} catch (e: Exception) {
Log.w(TAG, "processChunk: ${e.message}")
}
}
Log.i(TAG, "Capture-Loop beendet")
}
/** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
private fun processChunk(audio: ShortArray) {
// 1) Audio → mel (output (1, 1, frames, 32))
val floats = FloatArray(audio.size) { audio[it].toFloat() }
val melTensor = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(floats),
longArrayOf(1L, audio.size.toLong()),
)
val melResult = melSession!!.run(mapOf(melInputName to melTensor))
val melOut = melResult.get(0).value
melTensor.close()
@Suppress("UNCHECKED_CAST")
val mel4 = melOut as Array<Array<Array<FloatArray>>>
val frames = mel4[0][0]
// openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
for (frame in frames) {
val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
melBuffer.add(scaled)
}
melResult.close()
// 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
var pos = 0
for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
val src = melBuffer[melProcessedIdx + i]
System.arraycopy(src, 0, flat, pos, MEL_BINS)
pos += MEL_BINS
}
val embIn = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(flat),
longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
)
val embRes = embSession!!.run(mapOf(embInputName to embIn))
val embOut = embRes.get(0).value
embIn.close()
// Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
@Suppress("UNCHECKED_CAST")
val embArr = embOut as Array<Array<Array<FloatArray>>>
embBuffer.addLast(embArr[0][0][0].copyOf())
while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
embRes.close()
melProcessedIdx += EMBEDDING_STRIDE
}
// Mel-Buffer trimmen — verhindert Memory-Wachstum
if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) {
val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING
val newList = ArrayList<FloatArray>(melBuffer.size - keepFrom)
for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i])
melBuffer.clear()
melBuffer.addAll(newList)
melProcessedIdx = MEL_FRAMES_PER_EMBEDDING
}
// 3) Klassifikation — sobald wir 16 Embeddings haben
if (embBuffer.size < wwInputFrames) return
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
var p = 0
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
for (e in embBuffer) {
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
p += EMBEDDING_DIM
}
val wwIn = OnnxTensor.createTensor(
env,
FloatBuffer.wrap(flatEmb),
longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
)
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
val wwOut = wwRes.get(0).value
wwIn.close()
// Erwartete Output-Form: (1, 1) → Array<FloatArray>
@Suppress("UNCHECKED_CAST")
val score = (wwOut as Array<FloatArray>)[0][0]
wwRes.close()
if (score >= threshold) {
consecutiveAboveThreshold++
if (consecutiveAboveThreshold >= patience) {
val now = System.currentTimeMillis()
if (now - lastDetectionMs >= debounceMs) {
lastDetectionMs = now
consecutiveAboveThreshold = 0
Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName")
emitDetected()
}
}
} else {
consecutiveAboveThreshold = 0
}
}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class OpenWakeWordPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(OpenWakeWordModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,380 @@
package com.ariacockpit
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.LinkedBlockingQueue
/**
* Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM mit Pre-Roll.
*
* Pre-Roll: AudioTrack wird zwar direkt gebaut und gefuttert, aber play()
* wird erst aufgerufen wenn PREROLL_SECONDS Audio im Buffer ist. So hat
* der Stream Zeit einen Vorrat aufzubauen — wenn XTTS mit RTF>1 rendert
* (langsamer als Echtzeit), laeuft der Buffer trotzdem nicht leer.
*
* Flow:
* JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play())
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt
* Writer: spielt los sobald PREROLL erreicht ist
* JS: end() → wartet bis Queue leer, schließt
* JS: stop() → Hart stoppen (Cancel)
*/
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "PcmStreamPlayer"
// Fallback wenn JS keinen Wert uebergibt.
private const val DEFAULT_PREROLL_SECONDS = 3.5
// 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk.
// Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer
// unnoetig ist und bei kurzen Saetzen sogar stoeren kann.
private const val MIN_PREROLL_SECONDS = 0.0
private const val MAX_PREROLL_SECONDS = 10.0
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
private const val LEADING_SILENCE_SECONDS = 0.3
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
private const val TRAILING_SILENCE_SECONDS = 0.3
}
override fun getName() = "PcmStreamPlayer"
private var track: AudioTrack? = null
private val queue = LinkedBlockingQueue<ByteArray>()
private var writerThread: Thread? = null
@Volatile private var writerShouldStop = false
@Volatile private var endRequested = false
@Volatile private var prerollBytes: Int = 0
@Volatile private var playbackStarted = false
@Volatile private var bytesBuffered: Long = 0
@Volatile private var streamBytesPerFrame: Int = 2 // mono s16le default
// ── Lifecycle ──
@ReactMethod
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
try {
// Alte Session beenden falls vorhanden
stopInternal()
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
} else {
DEFAULT_PREROLL_SECONDS
}
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
// Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
// mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
// Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
// nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
prerollBytes = prerollTarget
bytesBuffered = 0
playbackStarted = false
streamBytesPerFrame = channels * 2 // s16 = 2 bytes per sample
val newTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelConfig)
.setEncoding(encoding)
.build(),
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
// Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s
// Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind —
// bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen.
// 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms
newTrack.setStartThresholdInFrames(startFrames)
Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)")
} catch (e: Exception) {
Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}")
}
}
track = newTrack
queue.clear()
writerShouldStop = false
endRequested = false
writerThread = Thread({
val t = track ?: return@Thread
try {
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (leadingBytes > 0) {
val silence = ByteArray(leadingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
// nicht erst bei Ankunft des ersten echten Chunks. Android's
// AudioTrack haelt den Play-State und wartet auf neue Samples.
// So verschluckt es keine Worte wenn der erste Chunk erst
// nach play()-Startup-Latenz eintrifft.
if (prerollBytes == 0 && !playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)")
} catch (e: Exception) {
Log.w(TAG, "play() sofort failed: ${e.message}")
}
}
// Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
var idleMs = 0L
val maxIdleMs = 120_000L
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
// Stille rein damit AudioTrack nicht underrunt waehrend die
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
val underrunGuardFrames = sampleRate / 10 // ~100ms
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
mainLoop@ while (!writerShouldStop) {
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
if (data == null) {
if (endRequested) {
// Falls play() noch gar nicht lief (Stream ohne data
// ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
// damit das finally{}-Wait nicht endlos blockt.
if (!playbackStarted) {
try { t.play(); playbackStarted = true } catch (_: Exception) {}
}
break@mainLoop
}
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
// Puffer leerzulaufen droht. Spotify resumed sonst nach
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
if (playbackStarted) {
val framesWritten = bytesBuffered / streamBytesPerFrame
val framesPlayed = t.playbackHeadPosition.toLong()
val framesInBuffer = framesWritten - framesPlayed
if (framesInBuffer < underrunGuardFrames) {
val fillBytes = silenceFillFrames * streamBytesPerFrame
val silence = ByteArray(fillBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
}
idleMs += 50L
if (idleMs >= maxIdleMs) {
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
break@mainLoop
}
continue@mainLoop
}
idleMs = 0L
// play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
// Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn
// play() erst gerufen wird nachdem der Buffer komplett gefuellt
// ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend
// der Track schon spielt — Underrun-Schutz fuettert ggf. Stille.
if (!playbackStarted) {
try {
t.play()
playbackStarted = true
Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)")
} catch (e: Exception) {
Log.w(TAG, "play() failed: ${e.message}")
}
}
var offset = 0
while (offset < data.size && !writerShouldStop) {
val written = t.write(data, offset, data.size - offset)
if (written <= 0) break
offset += written
}
bytesBuffered += data.size
}
// Trailing-Silence damit die letzten echten Samples garantiert
// durch das Hardware-Buffering kommen bevor stop() sie abschneidet
val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
if (trailingBytes > 0 && !writerShouldStop) {
val silence = ByteArray(trailingBytes)
var silOff = 0
while (silOff < silence.size && !writerShouldStop) {
val w = t.write(silence, silOff, silence.size - silOff)
if (w <= 0) break
silOff += w
}
bytesBuffered += silence.size
}
} catch (e: Exception) {
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
} finally {
// Warten bis alle geschriebenen Samples tatsaechlich abgespielt sind,
// sonst cuttet t.release() die letzten Sekunden ab.
try {
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
var lastPos = -1
var stalledCount = 0
var retried = false
while (!writerShouldStop) {
val pos = t.playbackHeadPosition
if (pos >= totalFrames) break
if (pos == lastPos) {
stalledCount++
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
// Geraeten (OnePlus A12) — play() nochmal anstossen.
if (stalledCount == 10 && pos == 0 && !retried) {
retried = true
Log.w(TAG, "playback nicht angefahren — retry play()")
try { t.play() } catch (e: Exception) {
Log.w(TAG, "retry play() failed: ${e.message}")
}
}
if (stalledCount > 40) {
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
break
}
} else {
stalledCount = 0
lastPos = pos
}
Thread.sleep(50)
}
Log.i(TAG, "Playback fertig: frames=$totalFrames pos=${t.playbackHeadPosition}")
} catch (_: Exception) {}
try { t.stop() } catch (_: Exception) {}
try { t.release() } catch (_: Exception) {}
// RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt).
// JS released erst JETZT den AudioFocus — sonst spielt Spotify
// beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer).
try {
val params = Arguments.createMap()
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmPlaybackFinished", params)
} catch (e: Exception) {
Log.w(TAG, "PlaybackFinished emit failed: ${e.message}")
}
}
}, "PcmStreamWriter").apply { start() }
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message, e)
}
}
@ReactMethod
fun writeChunk(base64Pcm: String, promise: Promise) {
try {
if (base64Pcm.isEmpty()) {
promise.resolve(true)
return
}
val bytes = Base64.decode(base64Pcm, Base64.DEFAULT)
queue.put(bytes)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("WRITE_FAILED", e.message, e)
}
}
/** Signalisiert: keine weiteren Chunks. Writer spielt aus, dann stoppt.
* Das Promise resolved erst wenn der Writer-Thread fertig ist —
* wichtig damit der Aufrufer den AudioFocus erst NACH dem letzten
* abgespielten Sample wieder freigibt (sonst dreht Spotify hoch
* waehrend das Pre-Roll noch ausspielt).
*/
@ReactMethod
fun end(promise: Promise) {
endRequested = true
val t = writerThread
if (t == null || !t.isAlive) {
promise.resolve(true)
return
}
// Im Hintergrund auf den Writer warten — kein Threading-Block fuer JS-Bridge
Thread({
try {
t.join(15_000) // hartes Cap, falls Writer haengt
} catch (_: InterruptedException) {}
promise.resolve(true)
}, "PcmStreamEndWaiter").start()
}
/** Harter Stop (Cancel) — Queue verwerfen. */
@ReactMethod
fun stop(promise: Promise) {
stopInternal()
promise.resolve(true)
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
private fun stopInternal() {
writerShouldStop = true
endRequested = true
queue.clear()
writerThread?.interrupt()
writerThread = null
val t = track
if (t != null) {
// pause() + flush() vor stop() — sonst spielt der Hardware-Buffer
// (200-500ms PCM-Samples) noch hörbar weiter, nachdem der User
// den Mute-Button gedrückt hat. Stefan-Bug-Report: "wenn ich auf
// den Mund halten Button klicke während ARIA redet stoppt sie nicht".
try { t.pause() } catch (_: Exception) {}
try { t.flush() } catch (_: Exception) {}
try { t.stop() } catch (_: Exception) {}
try { t.release() } catch (_: Exception) {}
}
track = null
}
override fun onCatalystInstanceDestroy() {
stopInternal()
super.onCatalystInstanceDestroy()
}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PcmStreamPlayerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PcmStreamPlayerModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,246 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.PowerManager
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.atomic.AtomicBoolean
/**
* PCM-Streaming-Recorder fuer die Streaming-Whisper-Bridge.
*
* Oeffnet AudioRecord (16 kHz mono s16le, VOICE_COMMUNICATION-Source mit
* automatischer AEC + NS) und feuert ~200ms-Chunks als base64-Event
* "PcmStreamChunk" an die JS-Bridge.
*
* audio.ts schickt die Chunks via RVS direkt an die whisper-bridge die
* dort einen ML-Endpointer laufen laesst — kein dB-VAD-Tuning mehr.
*
* Mic-Ownership: dieser Recorder DARF nicht gleichzeitig mit
* OpenWakeWord laufen — beide wollen AudioRecord vom MIC. Caller
* muss OpenWakeWord.stop() vor start() hier aufrufen und nach stop()
* hier wieder OpenWakeWord.start() — genau wie's audio.ts ohnehin
* macht.
*
* Events:
* "PcmStreamChunk" { pcm: base64-s16le, seq: N, ts: epochMs }
* "PcmStreamError" { error: string }
*/
class PcmStreamRecorderModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PcmStreamRecorder"
companion object {
private const val TAG = "PcmStreamRecorder"
private const val SAMPLE_RATE = 16000
// 200ms-Chunks: gross genug fuer wenig RVS-Overhead, klein genug damit
// der Endpointer im Whisper-Bridge granular sieht. 200ms ist auch das
// Whisper-VAD-Frame-Hop — passt also zu downstream.
private const val CHUNK_SAMPLES = 3200 // 200ms @ 16 kHz
private const val BYTES_PER_SAMPLE = 2 // s16
private const val CHUNK_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
}
private var audioRecord: AudioRecord? = null
private val running = AtomicBoolean(false)
private var captureThread: Thread? = null
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var agc: AutomaticGainControl? = null
// PARTIAL_WAKE_LOCK damit der JS-Bridge-Loop weiterlaeuft auch wenn das
// Display aus ist — sonst sammeln sich zwar Chunks in der nativen Queue
// an, aber emit() landet nicht zeitnah in JS und der Whisper-Bridge
// bekommt die Audio-Chunks erst beim App-Foreground-Resume.
private var wakeLock: PowerManager.WakeLock? = null
private var seq: Long = 0L
@ReactMethod
fun start(promise: Promise) {
if (running.get()) {
promise.resolve(true)
return
}
val perm = ContextCompat.checkSelfPermission(
reactApplicationContext, Manifest.permission.RECORD_AUDIO
)
if (perm != PackageManager.PERMISSION_GRANTED) {
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
return
}
try {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(CHUNK_BYTES * 4) // 4x Chunk-Size als Sicherheit
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuf,
)
if (record.state != AudioRecord.STATE_INITIALIZED) {
record.release()
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt? OpenWakeWord noch aktiv?)")
return
}
audioRecord = record
// AEC/NS/AGC explizit anschalten — manche Geraete liefern's via
// VOICE_COMMUNICATION zwar mit, aber Belt-and-Suspenders.
try {
if (AcousticEchoCanceler.isAvailable()) {
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
try {
if (NoiseSuppressor.isAvailable()) {
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
try {
if (AutomaticGainControl.isAvailable()) {
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
}
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
seq = 0L
running.set(true)
record.startRecording()
try {
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"AriaCockpit:PcmStreamRecord").apply {
setReferenceCounted(false)
acquire(8 * 60 * 60 * 1000L) // 8h Cap
}
} catch (e: Exception) {
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
}
captureThread = Thread({ captureLoop() }, "PcmStreamRecorderCapture").apply {
isDaemon = true
start()
}
Log.i(TAG, "Recording gestartet (16kHz mono s16le, ${CHUNK_SAMPLES} samples/chunk)")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
running.set(false)
audioRecord?.release()
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
running.set(false)
try {
captureThread?.join(1500)
} catch (_: InterruptedException) {}
captureThread = null
try { audioRecord?.stop() } catch (_: Exception) {}
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
Log.i(TAG, "Recording gestoppt (seq=$seq Chunks gesendet)")
promise.resolve(true)
}
@ReactMethod
fun isRecording(promise: Promise) {
promise.resolve(running.get())
}
private fun captureLoop() {
val buffer = ByteArray(CHUNK_BYTES)
val rec = audioRecord ?: return
try {
while (running.get()) {
var offset = 0
// Solange lesen bis ein voller 200ms-Chunk zusammen ist.
// AudioRecord.read kann weniger als angefordert liefern.
while (offset < CHUNK_BYTES && running.get()) {
val n = rec.read(buffer, offset, CHUNK_BYTES - offset)
if (n <= 0) {
if (!running.get()) break
// Fehlerzustand — kurze Pause, dann weiter probieren
Thread.sleep(5)
continue
}
offset += n
}
if (offset < CHUNK_BYTES) break
val b64 = Base64.encodeToString(buffer, Base64.NO_WRAP)
val ts = System.currentTimeMillis()
val params = Arguments.createMap().apply {
putString("pcm", b64)
// putLong existiert nicht in WritableMap — putDouble fuer ts/seq.
putDouble("seq", seq.toDouble())
putDouble("ts", ts.toDouble())
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmStreamChunk", params)
seq++
}
} catch (e: Exception) {
Log.e(TAG, "captureLoop crashed", e)
try {
val err = Arguments.createMap().apply {
putString("error", e.message ?: "unknown")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PcmStreamError", err)
} catch (_: Exception) {}
}
}
private fun releaseAudioEffects() {
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
try { agc?.release() } catch (_: Exception) {}
aec = null; ns = null; agc = null
}
private fun releaseWakeLock() {
try {
if (wakeLock?.isHeld == true) wakeLock?.release()
} catch (_: Exception) {}
wakeLock = null
}
// Damit RCTEventEmitter den Listener-Lifecycle nicht crasht
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PcmStreamRecorderPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PcmStreamRecorderModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,132 @@
package com.ariacockpit
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.concurrent.Executors
/**
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
*
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise — der Rest
* der App funktioniert wie bisher.
*
* State-Strings: "idle" | "ringing" | "offhook"
*/
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "PhoneCall"
companion object { private const val TAG = "PhoneCall" }
private var telephonyManager: TelephonyManager? = null
private var legacyListener: PhoneStateListener? = null
private var modernCallback: Any? = null // TelephonyCallback ab API 31
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
// wir haben einen Foreground-Service der das garantiert).
private val callbackExecutor = Executors.newSingleThreadExecutor()
@ReactMethod
fun start(promise: Promise) {
try {
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
if (perm != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
promise.resolve(false)
return
}
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) {
Log.w(TAG, "TelephonyManager nicht verfuegbar")
promise.resolve(false)
return
}
telephonyManager = tm
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
handleStateChange(state)
}
}
tm.registerTelephonyCallback(callbackExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
handleStateChange(state)
}
}
@Suppress("DEPRECATION")
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
legacyListener = l
}
Log.i(TAG, "PhoneCall-Listener aktiv")
promise.resolve(true)
} catch (e: Exception) {
Log.e(TAG, "start fehlgeschlagen", e)
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
}
}
@ReactMethod
fun stop(promise: Promise) {
try {
val tm = telephonyManager
if (tm != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
modernCallback = null
} else {
@Suppress("DEPRECATION")
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
legacyListener = null
}
}
telephonyManager = null
lastState = TelephonyManager.CALL_STATE_IDLE
promise.resolve(true)
} catch (e: Exception) {
promise.reject("STOP_FAILED", e.message ?: "")
}
}
private fun handleStateChange(state: Int) {
if (state == lastState) return
lastState = state
val name = when (state) {
TelephonyManager.CALL_STATE_RINGING -> "ringing"
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
TelephonyManager.CALL_STATE_IDLE -> "idle"
else -> return
}
Log.i(TAG, "Telefon-State: $name")
val params = Arguments.createMap().apply { putString("state", name) }
try {
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("PhoneCallStateChanged", params)
} catch (e: Exception) {
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
}
}
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class PhoneCallPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PhoneCallModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
<files-path name="files" path="." />
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>
+3 -1
View File
@@ -1,7 +1,9 @@
buildscript {
ext {
buildToolsVersion = "34.0.0"
minSdkVersion = 23
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
minSdkVersion = 24
compileSdkVersion = 34
targetSdkVersion = 34
ndkVersion = "25.1.8937393"
+3
View File
@@ -1,3 +1,6 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
// react-native-reanimated/plugin MUSS das LETZTE Plugin sein (Worklet-Transform).
// Nach dem Hinzufuegen einmalig Metro-Cache leeren: `npm start --reset-cache`.
plugins: ['react-native-reanimated/plugin'],
};
+15 -2
View File
@@ -167,10 +167,23 @@ export CI=true
if [ "$MODE" = "debug" ]; then
./gradlew assembleDebug
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
OUT_DIR="app/build/outputs/apk/debug"
else
./gradlew assembleRelease
APK_PATH="app/build/outputs/apk/release/app-release.apk"
OUT_DIR="app/build/outputs/apk/release"
fi
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
APK_PATH="$OUT_DIR/app-${MODE}.apk"
else
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
cd ..
exit 1
fi
cd ..
+3
View File
@@ -1,3 +1,6 @@
// react-native-gesture-handler MUSS als allererstes importiert werden
// (vor allem anderen), sonst crasht die Gesten-Erkennung auf Android.
import 'react-native-gesture-handler';
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
+21 -17
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.0.3.3",
"version": "0.2.3.2",
"private": true,
"scripts": {
"android": "react-native run-android",
@@ -10,31 +10,35 @@
"build:apk": "cd android && ./gradlew assembleRelease"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"@react-native-community/geolocation": "^3.2.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"react": "18.2.0",
"react-native": "0.73.4",
"@react-navigation/native": "^6.1.9",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "3.27.0",
"react-native-safe-area-context": "^4.8.2",
"react-native-audio-recorder-player": "^3.6.7",
"react-native-camera-kit": "^13.0.0",
"react-native-document-picker": "^9.1.1",
"react-native-sound": "^0.11.2",
"@react-native-community/geolocation": "^3.2.1",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.14.1",
"react-native-image-picker": "^7.1.0",
"react-native-permissions": "^4.1.4",
"react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.0",
"react-native-audio-recorder-player": "^3.6.7"
"react-native-reanimated": "3.6.2",
"react-native-safe-area-context": "^4.8.2",
"react-native-screens": "3.27.0",
"react-native-sound": "^0.11.2",
"react-native-svg": "^14.1.0",
"react-native-webview": "13.6.4"
},
"devDependencies": {
"typescript": "^5.3.3",
"@react-native/eslint-config": "^0.73.2",
"@react-native/metro-config": "^0.73.5",
"@react-native/typescript-config": "^0.73.1",
"@types/jest": "^29.5.11",
"@types/react": "^18.2.48",
"@types/react-native": "^0.73.0",
"@react-native/eslint-config": "^0.73.2",
"@react-native/typescript-config": "^0.73.1",
"@react-native/metro-config": "^0.73.5",
"metro-react-native-babel-preset": "^0.77.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
"metro-react-native-babel-preset": "^0.77.0",
"typescript": "^5.3.3"
}
}
Binary file not shown.
+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;
+272
View File
@@ -0,0 +1,272 @@
/**
* Memory-Browser — Liste mit Suche + Filter, Tap oeffnet MemoryDetailModal.
*
* Eingesetzt von:
* - SettingsScreen → Sektion "Gedächtnis" (kompletter Editor)
* - Inbox-Modal (Notizen-Button neben Lupe) — kann aber auch Bubbles
* aus dem Chat als zusaetzlichen Filter zeigen
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
ActivityIndicator,
FlatList,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
Alert,
Modal,
} from 'react-native';
import brainApi, { Memory } from '../services/brainApi';
import MemoryDetailModal from './MemoryDetailModal';
const TYPE_LABELS: Record<string, string> = {
identity: 'Identität', rule: 'Regeln', preference: 'Präferenzen',
tool: 'Tools', skill: 'Skills', fact: 'Fakten',
conversation: 'Konversation', reminder: 'Reminder',
};
const TYPE_OPTIONS = ['', 'identity', 'rule', 'preference', 'tool', 'skill', 'fact', 'conversation', 'reminder'];
interface Props {
/** Wenn gesetzt: nur diese IDs anzeigen (z.B. Inbox-Modal mit Chat-Bubbles-Filter). */
restrictToIds?: string[];
/** Headline ueber der Liste. */
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, onOpenMemory }) => {
const [items, setItems] = useState<Memory[]>([]);
const [filtered, setFiltered] = useState<Memory[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [q, setQ] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [pinnedFilter, setPinnedFilter] = useState<'all' | 'pinned' | 'cold'>('all');
const [showTypeMenu, setShowTypeMenu] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listMemories({ limit: 500 })
.then(setItems)
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// Filter clientseitig — bei kleiner DB (<1000) easy
useEffect(() => {
let out = items;
if (restrictToIds && restrictToIds.length) {
const set = new Set(restrictToIds);
out = out.filter(m => set.has(m.id));
}
if (typeFilter) out = out.filter(m => m.type === typeFilter);
if (pinnedFilter === 'pinned') out = out.filter(m => m.pinned);
else if (pinnedFilter === 'cold') out = out.filter(m => !m.pinned);
if (q.trim()) {
const needle = q.toLowerCase();
out = out.filter(m =>
(m.title || '').toLowerCase().includes(needle) ||
(m.content || '').toLowerCase().includes(needle) ||
(m.category || '').toLowerCase().includes(needle) ||
(m.tags || []).some(t => t.toLowerCase().includes(needle))
);
}
setFiltered(out);
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
const [newMemoryTitle, setNewMemoryTitle] = useState('');
const onAddNew = () => {
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={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
<View style={{flex:1}}>
<Text style={s.rowTitle} numberOfLines={1}>
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
{attCount > 0 ? <Text style={s.attBadge}>{` 📎${attCount}`}</Text> : null}
</Text>
<Text style={s.rowMeta} numberOfLines={1}>
{TYPE_LABELS[item.type] || item.type}
{item.category ? ` · [${item.category}]` : ''}
</Text>
<Text style={s.rowPreview} numberOfLines={2}>{item.content}</Text>
</View>
</TouchableOpacity>
);
};
return (
<View style={[s.container, flatStyle && {padding:0,backgroundColor:'transparent'}]}>
{title ? <Text style={s.heading}>{title}</Text> : null}
<View style={s.searchRow}>
<TextInput
style={s.search}
value={q}
onChangeText={setQ}
placeholder="Suche in Titel, Inhalt, Tags…"
placeholderTextColor="#555570"
/>
<TouchableOpacity style={s.iconBtn} onPress={load}>
<Text style={{color:'#0096FF'}}></Text>
</TouchableOpacity>
</View>
<View style={s.filterRow}>
<TouchableOpacity style={s.filterBtn} onPress={() => setShowTypeMenu(true)}>
<Text style={s.filterText}>{typeFilter ? (TYPE_LABELS[typeFilter] || typeFilter) : 'Alle Typen'} </Text>
</TouchableOpacity>
<TouchableOpacity style={s.filterBtn} onPress={() => {
setPinnedFilter(pinnedFilter === 'all' ? 'pinned' : pinnedFilter === 'pinned' ? 'cold' : 'all');
}}>
<Text style={s.filterText}>
{pinnedFilter === 'pinned' ? '📌 Nur Pinned' : pinnedFilter === 'cold' ? 'Nur Cold' : 'Alle'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.filterBtn,{backgroundColor:'#0096FF'}]} onPress={onAddNew}>
<Text style={[s.filterText,{color:'#fff'}]}>+ Neu</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop:20}} />
) : (
<FlatList
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)'}
</Text>
}
contentContainerStyle={{paddingBottom:20}}
/>
)}
<Text style={s.footer}>
{filtered.length}/{items.length} Memories
</Text>
{/* Type-Filter-Auswahl */}
<Modal visible={showTypeMenu} transparent animationType="fade" onRequestClose={() => setShowTypeMenu(false)}>
<TouchableOpacity style={s.menuBack} activeOpacity={1} onPress={() => setShowTypeMenu(false)}>
<View style={s.menuBox}>
{TYPE_OPTIONS.map(t => (
<TouchableOpacity
key={t || 'all'}
style={s.menuItem}
onPress={() => { setTypeFilter(t); setShowTypeMenu(false); }}
>
<Text style={s.menuItemText}>
{t ? (TYPE_LABELS[t] || t) : 'Alle Typen'}
</Text>
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
{/* 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>
);
};
const s = StyleSheet.create({
container: { flex:1, padding:8, backgroundColor:'#0D0D1A' },
heading: { color:'#0096FF', fontWeight:'bold', fontSize:14, marginBottom:8 },
searchRow: { flexDirection:'row', gap:6, marginBottom:6 },
search: { flex:1, backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:6, fontSize:13 },
iconBtn: { paddingHorizontal:12, justifyContent:'center', backgroundColor:'#1E1E2E', borderRadius:6 },
filterRow: { flexDirection:'row', gap:6, marginBottom:8 },
filterBtn: { backgroundColor:'#1E1E2E', paddingHorizontal:10, paddingVertical:6, borderRadius:6 },
filterText: { color:'#E0E0F0', fontSize:12 },
err: { color:'#FF6B6B', fontSize:12, marginVertical:6 },
row: { backgroundColor:'#1E1E2E', padding:10, borderRadius:6, marginBottom:6 },
rowTitle: { color:'#E0E0F0', fontWeight:'600', fontSize:13 },
attBadge: { color:'#34C759', fontWeight:'normal', fontSize:11 },
rowMeta: { color:'#8888AA', fontSize:11, marginTop:2 },
rowPreview: { color:'#666680', fontSize:11, marginTop:4 },
footer: { color:'#555570', fontSize:10, textAlign:'center', paddingVertical:6 },
menuBack: { flex:1, backgroundColor:'rgba(0,0,0,0.7)', justifyContent:'center', alignItems:'center' },
menuBox: { backgroundColor:'#0D0D1A', borderRadius:8, paddingVertical:4, minWidth:200 },
menuItem: { paddingVertical:10, paddingHorizontal:14 },
menuItemText: { color:'#E0E0F0', fontSize:13 },
});
export default MemoryBrowser;
@@ -0,0 +1,364 @@
/**
* Memory-Detail-Modal — Anzeige + Edit eines einzelnen Memory-Eintrags.
*
* Zwei Modi:
* - read-only: zeigt alle Felder + Anhang-Vorschau (Klick auf Bild = Vollbild)
* - edit: Form mit Save/Delete/Anhang-hochladen
*
* Memory-Daten werden beim Oeffnen aus dem Brain (via brainApi → RVS) frisch
* gezogen. Optimistic Updates sind explizit nicht da — der DB-Stand ist die
* Truth.
*/
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker';
import RNFS from 'react-native-fs';
import brainApi, { Memory, MemoryAttachment } from '../services/brainApi';
interface Props {
memoryId: string | null;
visible: boolean;
onClose: () => void;
onDeleted?: (id: string) => void;
}
const TYPE_OPTIONS = [
{ value: 'identity', label: 'identity (FEST)' },
{ value: 'rule', label: 'rule (FEST)' },
{ value: 'preference', label: 'preference (FEST)' },
{ value: 'tool', label: 'tool (FEST)' },
{ value: 'skill', label: 'skill (FEST)' },
{ value: 'fact', label: 'fact (Cold)' },
{ value: 'conversation', label: 'conversation (Cold)' },
{ value: 'reminder', label: 'reminder (Cold)' },
];
export const MemoryDetailModal: React.FC<Props> = ({ memoryId, visible, onClose, onDeleted }) => {
const [memory, setMemory] = useState<Memory | null>(null);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// Edit-Felder
const [eTitle, setETitle] = useState('');
const [eContent, setEContent] = useState('');
const [eCategory, setECategory] = useState('');
const [eTags, setETags] = useState('');
const [ePinned, setEPinned] = useState(false);
// Bild-Vollbild
const [fullscreen, setFullscreen] = useState<string | null>(null);
// Memory laden beim Oeffnen
useEffect(() => {
if (!visible || !memoryId) {
setMemory(null); setEditing(false); setErr(null); return;
}
setLoading(true); setErr(null);
brainApi.getMemory(memoryId)
.then(m => {
setMemory(m);
setETitle(m.title || '');
setEContent(m.content || '');
setECategory(m.category || '');
setETags((m.tags || []).join(', '));
setEPinned(!!m.pinned);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [visible, memoryId]);
const reload = () => {
if (!memoryId) return;
setLoading(true);
brainApi.getMemory(memoryId)
.then(m => setMemory(m))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
};
const onSave = async () => {
if (!memoryId) return;
setSaving(true); setErr(null);
try {
const tags = eTags.split(',').map(t => t.trim()).filter(Boolean);
const m = await brainApi.updateMemory(memoryId, {
title: eTitle.trim(),
content: eContent.trim(),
category: eCategory.trim(),
tags,
pinned: ePinned,
});
setMemory(m);
setEditing(false);
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setSaving(false);
}
};
const onDelete = () => {
if (!memoryId || !memory) return;
Alert.alert(
'Memory loeschen?',
`"${memory.title}"\n\nWird permanent aus der DB entfernt, inkl. aller Anhaenge.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: async () => {
try {
await brainApi.deleteMemory(memoryId);
if (onDeleted) onDeleted(memoryId);
onClose();
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
},
},
],
);
};
const onPickAndUpload = async () => {
if (!memoryId) return;
try {
const picked: DocumentPickerResponse[] = await DocumentPicker.pick({
type: [DocumentPicker.types.images, DocumentPicker.types.pdf, DocumentPicker.types.allFiles],
copyTo: 'cachesDirectory',
});
for (const f of picked) {
setBusy(`Lade ${f.name}`);
// RNFS lesen → base64 → API
const localPath = (f.fileCopyUri || f.uri).replace(/^file:\/\//, '');
const b64 = await RNFS.readFile(localPath, 'base64');
await brainApi.uploadAttachment(memoryId, f.name || 'datei', b64);
}
setBusy(null);
reload();
} catch (e: any) {
setBusy(null);
if (DocumentPicker.isCancel(e)) return;
Alert.alert('Upload-Fehler', String(e?.message || e));
}
};
const onDeleteAttachment = (att: MemoryAttachment) => {
if (!memoryId) return;
Alert.alert(
'Anhang loeschen?',
`"${att.name}"`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: async () => {
try {
const m = await brainApi.deleteAttachment(memoryId, att.name);
setMemory(m);
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
},
},
],
);
};
const onTapAttachment = async (att: MemoryAttachment) => {
if (!memoryId) return;
if ((att.mime || '').startsWith('image/')) {
try {
setBusy('Lade Bild…');
const data = await brainApi.getAttachmentBytes(memoryId, att.name);
// Temp-File schreiben damit <Image source={uri: file://...}> es zeigen kann
const safe = att.name.replace(/[^A-Za-z0-9._-]/g, '_');
const localPath = `${RNFS.CachesDirectoryPath}/memory_${memoryId}_${safe}`;
await RNFS.writeFile(localPath, data.base64, 'base64');
setBusy(null);
setFullscreen('file://' + localPath);
} catch (e: any) {
setBusy(null);
Alert.alert('Fehler', String(e?.message || e));
}
} else {
Alert.alert('Anhang', `${att.name}\n${att.mime}\n${att.size} Byte\n\nPfad: ${att.path}`);
}
};
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={s.backdrop}>
<View style={s.box}>
<View style={s.header}>
<Text style={s.title}>{editing ? 'Memory bearbeiten' : 'Memory-Detail'}</Text>
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={s.closeX}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={s.body} contentContainerStyle={{paddingBottom:20}}>
{loading ? (
<ActivityIndicator color="#0096FF" style={{marginTop:30}} />
) : err && !memory ? (
<Text style={s.err}>{err}</Text>
) : memory ? (
editing ? (
<View>
<Text style={s.label}>Typ</Text>
<Text style={{color:'#888',fontSize:12,marginBottom:8}}>{memory.type} (kann hier nicht geaendert werden)</Text>
<Text style={s.label}>Titel</Text>
<TextInput style={s.input} value={eTitle} onChangeText={setETitle} />
<Text style={s.label}>Inhalt</Text>
<TextInput
style={[s.input, {minHeight:120, textAlignVertical:'top'}]}
value={eContent}
onChangeText={setEContent}
multiline
/>
<Text style={s.label}>Kategorie</Text>
<TextInput style={s.input} value={eCategory} onChangeText={setECategory} />
<Text style={s.label}>Tags (komma-getrennt)</Text>
<TextInput style={s.input} value={eTags} onChangeText={setETags} />
<View style={{flexDirection:'row',alignItems:'center',marginTop:10,gap:8}}>
<Switch value={ePinned} onValueChange={setEPinned} />
<Text style={{color:'#E0E0F0'}}>📌 Pinned (immer im System-Prompt)</Text>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
<View style={{flexDirection:'row',gap:8,marginTop:14}}>
<TouchableOpacity style={[s.btn,s.btnSecondary]} onPress={() => setEditing(false)} disabled={saving}>
<Text style={s.btnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn,s.btnPrimary,{flex:1}]} onPress={onSave} disabled={saving}>
<Text style={s.btnText}>{saving ? 'Speichere…' : 'Speichern'}</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View>
<View style={{flexDirection:'row',alignItems:'flex-start',justifyContent:'space-between'}}>
<Text style={s.bigTitle}>{memory.pinned ? '📌 ' : ''}{memory.title}</Text>
<TouchableOpacity onPress={() => setEditing(true)} style={s.iconBtn}>
<Text style={s.iconBtnText}></Text>
</TouchableOpacity>
</View>
<Text style={s.meta}>
{memory.type}{memory.category ? ` · [${memory.category}]` : ''}
</Text>
{(memory.tags || []).length > 0 ? (
<View style={s.tagsRow}>
{memory.tags.map(t => <Text key={t} style={s.tag}>{t}</Text>)}
</View>
) : null}
<Text style={s.contentBlock}>{memory.content}</Text>
<Text style={s.sectionHead}>📎 Anhaenge</Text>
{(memory.attachments || []).length === 0 ? (
<Text style={{color:'#555570',fontStyle:'italic',fontSize:12}}>(keine)</Text>
) : (
(memory.attachments || []).map((a) => {
const isImage = (a.mime || '').startsWith('image/');
return (
<View key={a.name} style={s.attRow}>
<TouchableOpacity style={{flexDirection:'row',alignItems:'center',gap:8,flex:1}} onPress={() => onTapAttachment(a)}>
<Text style={{fontSize:18}}>{isImage ? '🖼️' : '📄'}</Text>
<View style={{flex:1}}>
<Text style={{color:'#E0E0F0',fontSize:12}} numberOfLines={1}>{a.name}</Text>
<Text style={{color:'#555570',fontSize:10}}>{a.mime} · {Math.round(a.size/1024)} KB</Text>
</View>
</TouchableOpacity>
<TouchableOpacity onPress={() => onDeleteAttachment(a)} style={s.attDelete}>
<Text style={{color:'#FF6B6B',fontSize:12}}>🗑</Text>
</TouchableOpacity>
</View>
);
})
)}
<TouchableOpacity style={[s.btn,s.btnSecondary,{marginTop:8}]} onPress={onPickAndUpload}>
<Text style={s.btnText}> Datei anhaengen</Text>
</TouchableOpacity>
{busy ? <Text style={{color:'#8888AA',fontSize:11,marginTop:4}}>{busy}</Text> : null}
<Text style={s.timestamps}>
angelegt: {(memory.created_at || '').slice(0,16).replace('T',' ')}{'\n'}
geaendert: {(memory.updated_at || '').slice(0,16).replace('T',' ')}{'\n'}
id: {memory.id}
</Text>
<TouchableOpacity style={[s.btn,s.btnDanger,{marginTop:14}]} onPress={onDelete}>
<Text style={s.btnText}>🗑 Memory komplett loeschen</Text>
</TouchableOpacity>
</View>
)
) : null}
</ScrollView>
</View>
</View>
<Modal visible={!!fullscreen} transparent onRequestClose={() => setFullscreen(null)}>
<TouchableOpacity style={s.fsBack} onPress={() => setFullscreen(null)}>
{fullscreen ? <Image source={{uri:fullscreen}} style={s.fsImg} resizeMode="contain" /> : null}
</TouchableOpacity>
</Modal>
</Modal>
);
};
const s = StyleSheet.create({
backdrop: { flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'flex-end' },
box: { backgroundColor:'#0D0D1A', borderTopLeftRadius:12, borderTopRightRadius:12, maxHeight:'92%' },
header: { flexDirection:'row', justifyContent:'space-between', alignItems:'center', padding:14, borderBottomColor:'#1E1E2E', borderBottomWidth:1 },
title: { color:'#FFD60A', fontWeight:'bold', fontSize:15 },
closeX: { color:'#8888AA', fontSize:24, paddingHorizontal:6 },
body: { padding:14 },
err: { color:'#FF6B6B', fontSize:12, marginTop:8 },
label: { color:'#8888AA', fontSize:11, marginBottom:3, marginTop:8 },
input: { backgroundColor:'#080810', borderColor:'#1E1E2E', borderWidth:1, borderRadius:4, padding:8, color:'#E0E0F0', fontSize:13 },
bigTitle: { color:'#E0E0F0', fontWeight:'bold', fontSize:16, flex:1, marginRight:6 },
iconBtn: { padding:6, backgroundColor:'#1E1E2E', borderRadius:6 },
iconBtnText: { color:'#0096FF', fontSize:14 },
meta: { color:'#8888AA', fontSize:11, marginTop:4 },
tagsRow: { flexDirection:'row', flexWrap:'wrap', gap:4, marginTop:6 },
tag: { backgroundColor:'#1E1E2E', color:'#8888AA', fontSize:10, paddingHorizontal:6, paddingVertical:2, borderRadius:8 },
contentBlock: { color:'#E0E0F0', fontSize:13, marginTop:12, lineHeight:18 },
sectionHead: { color:'#0096FF', fontSize:11, marginTop:14, marginBottom:6, textTransform:'uppercase', letterSpacing:0.5 },
attRow: { flexDirection:'row', alignItems:'center', backgroundColor:'#080810', padding:8, borderRadius:6, marginBottom:4, gap:6 },
attDelete: { padding:4 },
timestamps: { color:'#555570', fontSize:10, marginTop:12, fontFamily:'monospace' },
btn: { paddingVertical:10, paddingHorizontal:14, borderRadius:6, alignItems:'center' },
btnPrimary: { backgroundColor:'#0096FF' },
btnSecondary: { backgroundColor:'#1E1E2E' },
btnDanger: { backgroundColor:'#3B1010', borderWidth:1, borderColor:'#FF6B6B' },
btnText: { color:'#fff', fontSize:13, fontWeight:'600' },
fsBack: { flex:1, backgroundColor:'rgba(0,0,0,0.95)', justifyContent:'center', alignItems:'center' },
fsImg: { width:'95%', height:'85%' },
});
export default MemoryDetailModal;
+88
View File
@@ -0,0 +1,88 @@
/**
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung,
* plus Inline-Image-Rendering wenn der Text Bild-URLs enthaelt.
*
* - Markdown-Syntax `![alt](url)` und plain `https://...image.png` werden
* erkannt — die URL bleibt im Text sichtbar (klickbar via Linkify),
* zusaetzlich wird das Bild als <Image> oder <SvgUri> drunter gerendert.
* - Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
* <Text> mit eigenem onPress — Nested Text mit onPress fing die Long-Press-
* Geste ab, damit war Markieren+Kopieren defekt.
*/
import React, { useEffect, useState } from 'react';
import { View, Text, Image, TextStyle, StyleProp } from 'react-native';
import { SvgUri } from 'react-native-svg';
interface Props {
text: string;
style?: StyleProp<TextStyle>;
}
// Bild-URL-Pattern: http(s)://... endend auf gaengige Bild-Endungen.
const IMG_URL_RE = /https?:\/\/[^\s)<"']+\.(?:jpe?g|png|gif|webp|bmp|ico|svg)(?:\?[^\s)<"']*)?/gi;
function extractImageUrls(text: string): string[] {
const urls = new Set<string>();
const matches = text.match(IMG_URL_RE);
if (matches) matches.forEach(u => urls.add(u));
return Array.from(urls);
}
const SVG_RE = /\.svg(?:\?|$)/i;
/** Image mit dynamischer Aspect-Ratio aus echten Bilddimensionen.
* SVGs werden ueber react-native-svg gerendert (kein Image.getSize). */
const InlineImage: React.FC<{ uri: string }> = ({ uri }) => {
const isSvg = SVG_RE.test(uri);
const [aspectRatio, setAspectRatio] = useState<number>(1);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (isSvg) return; // Image.getSize geht fuer SVG nicht
let cancelled = false;
Image.getSize(
uri,
(w, h) => { if (!cancelled && w > 0 && h > 0) setAspectRatio(Math.max(0.5, Math.min(2.5, w / h))); },
() => { if (!cancelled) setFailed(true); },
);
return () => { cancelled = true; };
}, [uri, isSvg]);
if (failed) return null;
if (isSvg) {
return (
<View style={{ marginTop: 8, width: 260, height: 260, backgroundColor: '#0D0D1A', borderRadius: 8, alignItems: 'center', justifyContent: 'center' }}>
<SvgUri uri={uri} width="100%" height="100%" onError={() => setFailed(true)} />
</View>
);
}
return (
<Image
source={{ uri }}
style={{ width: 260, aspectRatio, borderRadius: 8, marginTop: 8, backgroundColor: '#0D0D1A' }}
resizeMode="cover"
onError={() => setFailed(true)}
/>
);
};
const MessageText: React.FC<Props> = ({ text, style }) => {
const imageUrls = extractImageUrls(text || '');
if (imageUrls.length === 0) {
return (
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
);
}
return (
<View>
<Text style={style} selectable dataDetectorType="all">
{text}
</Text>
{imageUrls.map(u => <InlineImage key={u} uri={u} />)}
</View>
);
};
export default MessageText;
+614
View File
@@ -0,0 +1,614 @@
/**
* OAuth-Browser — Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer
* Credentials. Eingesetzt von SettingsScreen → Sektion "OAuth-Apps".
*
* Pro Service:
* - Status (verbunden / konfiguriert / leer)
* - client_id + client_secret (Passwort-Toggle)
* - Bei Custom-Services: auch auth_url + token_url + scopes editierbar
* - "Autorisieren ↗" oeffnet die Provider-Auth-Seite im System-Browser
* - "Abmelden" + (bei Custom) "🗑 Service entfernen"
*
* Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes.
*
* Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur
* Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles
* ueber TLS (wss://) — der Wert ist nie im Klartext im Netz unterwegs.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Linking,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi';
const COL_OK = '#34C759';
const COL_PENDING = '#FFD60A';
const COL_OFF = '#666680';
const COL_ERR = '#FF6B6B';
function fmtExpiry(secs: number | null | undefined): string {
if (secs == null) return '';
if (secs <= 0) return 'abgelaufen';
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.round(secs / 60)} min`;
if (secs < 86400) return `${Math.round(secs / 3600)} h`;
return `${Math.round(secs / 86400)} Tage`;
}
interface MergedService extends OAuthServiceStatus {
app?: OAuthAppConfig;
isDefault: boolean;
}
export const OAuthBrowser: React.FC = () => {
const [services, setServices] = useState<MergedService[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [editService, setEditService] = useState<MergedService | null>(null);
const [showNew, setShowNew] = useState(false);
const load = useCallback(() => {
setLoading(true); setErr(null);
Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()])
.then(([statusRes, appsRes]) => {
const apps = appsRes.apps || {};
const defaults = new Set(appsRes.defaults || []);
const items: MergedService[] = (statusRes.services || []).map(s => ({
...s,
app: apps[s.service],
isDefault: defaults.has(s.service),
}));
items.sort((a, b) => {
if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1;
if (a.configured !== b.configured) return a.configured ? -1 : 1;
return a.service.localeCompare(b.service);
});
setServices(items);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const renderItem = ({ item }: { item: MergedService }) => {
let statusColor: string = COL_OFF;
let statusIcon = '⚫';
let statusText = 'nicht konfiguriert';
if (item.authenticated) {
statusColor = COL_OK; statusIcon = '✅';
statusText = `verbunden${item.expiresInSec != null ? ' · noch ' + fmtExpiry(item.expiresInSec) : ''}`;
} else if (item.configured) {
statusColor = COL_PENDING; statusIcon = '🟡';
statusText = 'konfiguriert, nicht autorisiert';
}
return (
<TouchableOpacity style={s.row} onPress={() => setEditService(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2}}>
<Text style={{color: '#E0E0F0', fontWeight: '600', fontSize: 14, textTransform: 'capitalize'}}>{item.service}</Text>
{!item.isDefault ? (
<Text style={{color: '#8888AA', fontSize: 10}}>(custom)</Text>
) : null}
</View>
<Text style={{color: statusColor, fontSize: 12}}>{statusIcon} {statusText}</Text>
</View>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
<View style={s.toolbar}>
<Text style={{color: '#8888AA', fontSize: 11, flex: 1}}>
Verbinde ARIA mit externen Services (Spotify u.a.).
</Text>
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
<Text style={{fontSize: 13, color: '#fff', fontWeight: '700'}}>+ Custom</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && services.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={services}
keyExtractor={s => s.service}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
(keine OAuth-Services frag ARIA: "verbinde mich mit X")
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{editService ? (
<OAuthEditModal
service={editService}
onClose={() => setEditService(null)}
onReload={() => { setEditService(null); load(); }}
/>
) : null}
{showNew ? (
<OAuthCustomNewModal
onClose={() => setShowNew(false)}
onCreated={() => { setShowNew(false); load(); }}
/>
) : null}
</View>
);
};
// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ──────────
interface EditProps {
service: MergedService;
onClose: () => void;
onReload: () => void;
}
const OAuthEditModal: React.FC<EditProps> = ({ service: svc, onClose, onReload }) => {
const [clientId, setClientId] = useState(svc.app?.client_id || '');
const [clientSecret, setClientSecret] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || '');
const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || '');
const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' '));
const [saving, setSaving] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const save = async () => {
if (!clientId.trim()) {
Alert.alert('Fehler', 'client_id darf nicht leer sein.');
return;
}
setSaving(true);
const body: any = {
service: svc.service,
client_id: clientId.trim(),
};
if (clientSecret) body.client_secret = clientSecret;
if (authUrl.trim()) body.auth_url = authUrl.trim();
if (tokenUrl.trim()) body.token_url = tokenUrl.trim();
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
try {
await brainApi.saveOAuthApp(body);
onReload();
} catch (e: any) {
Alert.alert('Speichern fehlgeschlagen', String(e?.message || e));
} finally {
setSaving(false);
}
};
const authorize = async () => {
if (!svc.configured) {
Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.');
return;
}
try {
const r = await brainApi.authorizeOAuth(svc.service);
// Im System-Browser oeffnen — InAppBrowser wuerde z.T. von Providern blockiert
const ok = await Linking.canOpenURL(r.url);
if (!ok) {
Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.');
return;
}
Linking.openURL(r.url);
Alert.alert(
'Im Browser anmelden',
`Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen — ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`,
[{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }],
);
} catch (e: any) {
Alert.alert('Authorize fehlgeschlagen', String(e?.message || e));
}
};
const revoke = () => {
Alert.alert(
'Abmelden?',
`Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Abmelden',
style: 'destructive',
onPress: async () => {
try { await brainApi.revokeOAuth(svc.service); onReload(); }
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
},
},
],
);
};
const removeService = () => {
Alert.alert(
'Service komplett entfernen?',
`"${svc.service}" wird inkl. client_id/secret und Token geloescht.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: async () => {
try { await brainApi.deleteOAuthApp(svc.service); onReload(); }
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
},
},
],
);
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={s.modalTitle} numberOfLines={1}>{svc.service}</Text>
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
</TouchableOpacity>
</View>
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
{svc.authenticated ? (
<View style={[s.metaBox, {borderLeftWidth: 3, borderLeftColor: COL_OK, marginBottom: 12}]}>
<Text style={[s.meta, {color: COL_OK, fontWeight: '700'}]}>
verbunden{svc.expiresInSec != null ? ` · Token noch ${fmtExpiry(svc.expiresInSec)}` : ''}
</Text>
{svc.hasRefresh ? <Text style={s.meta}>refresh_token vorhanden auto-renew aktiv</Text>
: <Text style={[s.meta, {color: COL_ERR}]}>KEIN refresh_token Token verfaellt komplett</Text>}
{svc.scope ? <Text style={s.meta}>scopes: {svc.scope}</Text> : null}
</View>
) : null}
<Text style={s.label}>client_id</Text>
<TextInput
style={s.input}
value={clientId}
onChangeText={setClientId}
placeholder="aus dem Provider-Developer-Dashboard"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>
client_secret {svc.app?.has_client_secret ? '— gespeichert (leer = behalten)' : '— fehlt'}
</Text>
<View style={{flexDirection: 'row', gap: 6}}>
<TextInput
style={[s.input, {flex: 1}]}
value={clientSecret}
onChangeText={setClientSecret}
placeholder={svc.app?.has_client_secret ? '(neuen eintragen oder leer lassen)' : 'aus dem Dashboard'}
placeholderTextColor="#444460"
secureTextEntry={!showSecret}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', justifyContent: 'center'}]}
onPress={() => setShowSecret(v => !v)}
>
<Text style={{color: '#8888AA', fontSize: 14}}>{showSecret ? '🙈' : '👁'}</Text>
</TouchableOpacity>
</View>
{/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan
nicht ausversehen die Spotify-URLs ueberschreibt. */}
{svc.isDefault ? (
<TouchableOpacity onPress={() => setShowAdvanced(v => !v)} style={{marginTop: 12}}>
<Text style={{color: '#666680', fontSize: 11, fontStyle: 'italic'}}>
{showAdvanced ? '▼' : '▶'} Default-URLs ueberschreiben (advanced)
</Text>
</TouchableOpacity>
) : null}
{(!svc.isDefault || showAdvanced) ? (
<View style={{marginTop: 8}}>
<Text style={s.label}>auth_url</Text>
<TextInput
style={s.input}
value={authUrl}
onChangeText={setAuthUrl}
placeholder="https://provider.com/oauth/authorize"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>token_url</Text>
<TextInput
style={s.input}
value={tokenUrl}
onChangeText={setTokenUrl}
placeholder="https://provider.com/oauth/token"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>scopes (space-separated)</Text>
<TextInput
style={s.input}
value={scopes}
onChangeText={setScopes}
placeholder="read write user.email"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
<View style={{flexDirection: 'row', gap: 8, marginTop: 16}}>
<TouchableOpacity
style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]}
onPress={save}
disabled={saving}
>
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
{saving ? 'speichert...' : 'Speichern'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, {backgroundColor: svc.configured ? '#34C759' : '#1E1E2E', flex: 1}]}
onPress={authorize}
disabled={!svc.configured}
>
<Text style={{color: svc.configured ? '#fff' : '#555570', textAlign: 'center', fontWeight: '700'}}>
Autorisieren
</Text>
</TouchableOpacity>
</View>
{svc.authenticated ? (
<TouchableOpacity
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 12}]}
onPress={revoke}
>
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>Abmelden (Token loeschen)</Text>
</TouchableOpacity>
) : null}
{!svc.isDefault ? (
<TouchableOpacity
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 8}]}
onPress={removeService}
>
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>🗑 Service komplett entfernen</Text>
</TouchableOpacity>
) : null}
<View style={{height: 30}} />
</ScrollView>
</View>
</Modal>
);
};
// ── Neuer Custom-Provider ──────────────────────────────────────────
interface NewProps {
onClose: () => void;
onCreated: () => void;
}
const OAuthCustomNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
const [name, setName] = useState('');
const [authUrl, setAuthUrl] = useState('https://');
const [tokenUrl, setTokenUrl] = useState('https://');
const [scopes, setScopes] = useState('');
const [creating, setCreating] = useState(false);
const create = async () => {
const svc = name.trim().toLowerCase();
if (!/^[a-z0-9_-]+$/.test(svc)) {
Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -');
return;
}
if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) {
Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.');
return;
}
setCreating(true);
try {
const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() };
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
await brainApi.saveOAuthApp(body);
onCreated();
} catch (e: any) {
Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e));
} finally {
setCreating(false);
}
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={s.modalTitle}>Custom OAuth-Provider</Text>
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
</TouchableOpacity>
</View>
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 12}}>
Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret
kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim
Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt.
</Text>
<Text style={s.label}>Service-Name (z.B. dropbox, discord)</Text>
<TextInput
style={s.input}
value={name}
onChangeText={setName}
placeholder="kurz, a-z 0-9 _ -"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>auth_url</Text>
<TextInput
style={s.input}
value={authUrl}
onChangeText={setAuthUrl}
placeholder="https://provider.com/oauth/authorize"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>token_url</Text>
<TextInput
style={s.input}
value={tokenUrl}
onChangeText={setTokenUrl}
placeholder="https://provider.com/oauth/token"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={s.label}>scopes (space-separated, optional)</Text>
<TextInput
style={s.input}
value={scopes}
onChangeText={setScopes}
placeholder="read write user.email"
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
<View style={{flexDirection: 'row', gap: 8, marginTop: 20}}>
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]} onPress={onClose}>
<Text style={{color: '#8888AA', textAlign: 'center'}}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]} onPress={create} disabled={creating}>
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
{creating ? '...' : 'Anlegen'}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</Modal>
);
};
// ── Styles ─────────────────────────────────────────────────────────
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
backgroundColor: '#1A1A2E',
},
row: {
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
err: {
color: '#FF6B6B',
padding: 12,
fontSize: 12,
},
modal: {
flex: 1,
backgroundColor: '#0D0D1A',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalTitle: {
color: '#E0E0F0',
fontSize: 16,
fontWeight: '700',
flex: 1,
marginRight: 12,
textTransform: 'capitalize',
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 12,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
fontFamily: 'monospace',
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default OAuthBrowser;
+504
View File
@@ -0,0 +1,504 @@
/**
* Projekt-Übersicht + Switcher.
*
* Modal-Komponente die:
* - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt)
* - Die Projekt-Liste rendert (sortiert nach letzter Aktivität)
* - Per Tap zwischen Projekten wechseln lässt
* - Neue Projekte anlegen kann
* - Bestehende editieren/beenden/archivieren
*
* Eingesetzt von ChatScreen (über den Projekt-Indicator) und von
* SettingsScreen.tsx in der Section 'projects'.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
import rvs from '../services/rvs';
import projectFocus from '../services/projectFocus';
interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
visible?: boolean;
onClose?: () => void;
/** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale
* UI-Entscheidung, wechselt den Chat-Focus). */
onActiveChanged?: (project: Project | null) => void;
/** Der aktuell in der App fokussierte Kontext (App-lokale Source-of-Truth).
* Leer = Hauptchat. Steuert das ✓-FOCUS-Highlight. WICHTIG: der Drawer darf
* den Focus NICHT aus dem Brain-Status ableiten — im Multi-Threading gibt es
* kein globales active_project mehr (status.active ist null), das wuerde den
* Focus bei jedem Drawer-Oeffnen auf Hauptchat zuruecksetzen. */
currentFocusId?: string;
/** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id).
* Wenn geliefert: Status-Dot pro Zeile gerendert. */
queueStatus?: Record<string, { busy: boolean; queue_size: number }>;
}
function _fmtRel(unixSec: number): string {
if (!unixSec) return '?';
const diff = (Date.now() / 1000) - unixSec;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
if (diff < 86400 * 14) return `vor ${Math.floor(diff / 86400)} Tagen`;
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
}
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged, currentFocusId, queueStatus }) => {
const _statusDot = (pid: string) => {
const s = queueStatus?.[pid];
if (!s) return { color: '#555570', label: '' };
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
return { color: '#34C759', label: 'idle' };
};
const [projects, setProjects] = useState<Project[]>([]);
const [activeId, setActiveId] = useState<string>('');
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
// Render → useCallback re-runs → useEffect refeuert → infinite spinner).
const onActiveChangedRef = useRef(onActiveChanged);
useEffect(() => { onActiveChangedRef.current = onActiveChanged; }, [onActiveChanged]);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.getProjectStatus()
.then(status => {
// NUR die Projektliste + Queue uebernehmen. NICHT status.active in den
// App-Focus pushen — im Multi-Threading ist das Brain-active_project
// bedeutungslos (null), das wuerde den Focus bei jedem Drawer-Oeffnen
// auf Hauptchat zuruecksetzen und alle Nachrichten dort landen lassen.
setProjects(status.projects || []);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { if (visible) load(); }, [visible, load]);
// Highlight („✓ FOCUS") folgt dem App-Focus (Source-of-Truth), nicht dem
// Brain. switchTo setzt activeId zusaetzlich sofort fuer Instant-Feedback.
useEffect(() => { setActiveId(currentFocusId || ''); }, [currentFocusId]);
// Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig
useEffect(() => {
if (!visible) return;
const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); });
return () => unsub();
}, [visible, load]);
// Live-Sync: ein anderer Client (Diagnostic / andere App) hat ein Projekt
// geaendert (verstecken/anlegen/beenden/…) → project_changed ueber RVS →
// Liste neu laden, ohne dass Stefan manuell refreshen muss.
useEffect(() => {
if (!visible) return;
const unsub = rvs.onMessage((msg: any) => {
if (msg?.type === 'project_changed') load();
});
return () => unsub();
}, [visible, load]);
const switchTo = useCallback((id: string) => {
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
// Wir suchen das Projekt lokal aus der Liste, damit die App den Namen kennt.
setActiveId(id);
const p = id ? (projects.find(x => x.id === id) || null) : null;
onActiveChangedRef.current?.(p);
if (onClose) onClose();
}, [projects, onClose]);
const createProject = useCallback(() => {
const name = newName.trim();
if (!name) return;
brainApi.createProject({ name, description: newDesc.trim() })
.then(() => {
setNewName(''); setNewDesc(''); setNewOpen(false);
load();
})
.catch(e => Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e)));
}, [newName, newDesc, load]);
const openEdit = useCallback((p: Project) => {
setEditing(p);
setEditName(p.name);
setEditDesc(p.description || '');
setEditKind(p.kind === 'code' ? 'code' : 'chat');
}, []);
const saveEdit = useCallback(() => {
if (!editing) return;
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
const curKind = editing.kind === 'code' ? 'code' : 'chat';
if (editKind !== curKind) patch.kind = editKind;
if (Object.keys(patch).length === 0) { setEditing(null); return; }
brainApi.updateProject(editing.id, patch)
.then(() => {
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
setEditing(null); load();
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [editing, editName, editDesc, editKind, load]);
const endProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" beenden?`,
'Bleibt sichtbar, kann nicht mehr aktiv sein außer mit explizitem Wiedereintritt.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Beenden', onPress: () => {
brainApi.endProject(p.id).then(() => load()).catch(e => Alert.alert('Fehler', String(e?.message || e)));
}},
]);
}, [load]);
// Nach einer Projekt-Mutation die anderen Clients (Diagnostic, weitere
// App-Instanzen) live aktualisieren — via RVS project_changed. RVS echot
// NICHT an den Sender zurueck, darum laden wir lokal zusaetzlich selbst.
const broadcastProjectsChanged = useCallback(() => {
try { rvs.send('project_changed' as any, { reason: 'app' }); } catch {}
}, []);
const toggleHidden = useCallback((p: Project) => {
brainApi.setProjectHidden(p.id, !p.hidden)
.then(() => { broadcastProjectsChanged(); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [load, broadcastProjectsChanged]);
const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Archivieren', style: 'destructive', onPress: () => {
brainApi.archiveProject(p.id)
.then(() => { setEditing(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}},
]);
}, [load]);
// ── Render ────────────────────────────────────────────────
const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId;
const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{queueStatus && (
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{item.has_files && (
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
)}
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
{item.description ? (
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
) : null}
<Text style={s.rowMeta}>
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
{/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit
der Tap NICHT das Projekt wechselt. */}
<TouchableOpacity
onPress={() => toggleHidden(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={s.eyeBtn}
>
<Text style={s.eyeIcon}>{hidden ? '👁' : '🙈'}</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */}
<View style={s.header}>
{onClose && (
<TouchableOpacity onPress={onClose} style={s.headerBtn}>
<Text style={s.headerBtnText}></Text>
</TouchableOpacity>
)}
<Text style={s.headerTitle}>Projekte</Text>
<TouchableOpacity onPress={() => setNewOpen(true)} style={s.headerBtn}>
<Text style={[s.headerBtnText, { color: '#34C759' }]}>+ Neu</Text>
</TouchableOpacity>
</View>
{/* Hauptchat-Eintrag (immer oben) */}
{(() => {
const dot = _statusDot('__main__');
return (
<TouchableOpacity
onPress={() => switchTo('')}
style={[s.row, !activeId && s.rowActive]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{queueStatus && (
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
{!activeId && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
<Text style={s.rowMeta}>
Standard-Verlauf, keine Projekt-Zuordnung
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
</TouchableOpacity>
);
})()}
{/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */}
{(hiddenCount > 0 || showHidden) && (
<TouchableOpacity onPress={() => setShowHidden(v => !v)} style={s.hiddenToggle}>
<Text style={s.hiddenToggleText}>
{showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`}
</Text>
</TouchableOpacity>
)}
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
</View>
) : err ? (
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={visibleProjects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
projects.length > 0 ? (
<Text style={s.emptyText}>
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
Tipp 👁 Versteckte anzeigen".
</Text>
) : (
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
„Lass uns ein Projekt 'XY' anlegen".
</Text>
)
}
/>
)}
{/* Neu-Anlegen Modal */}
<Modal visible={newOpen} animationType="slide" transparent onRequestClose={() => setNewOpen(false)}>
<View style={s.modalOverlay}>
<View style={s.modalCard}>
<Text style={s.modalTitle}>Neues Projekt</Text>
<TextInput
value={newName}
onChangeText={setNewName}
placeholder="Name (z.B. 'Frankreich-Urlaub')"
placeholderTextColor="#555570"
style={s.input}
autoFocus
/>
<TextInput
value={newDesc}
onChangeText={setNewDesc}
placeholder="Beschreibung — kurz, hilft beim Wiederfinden"
placeholderTextColor="#555570"
style={[s.input, { height: 70 }]}
multiline
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={createProject} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
<Text style={s.modalBtnText}>Anlegen + aktivieren</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Edit Modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={() => setEditing(null)}>
<View style={s.modalOverlay}>
<View style={s.modalCard}>
<Text style={s.modalTitle}>Projekt bearbeiten</Text>
<TextInput
value={editName}
onChangeText={setEditName}
placeholder="Name"
placeholderTextColor="#555570"
style={s.input}
/>
<TextInput
value={editDesc}
onChangeText={setEditDesc}
placeholder="Beschreibung"
placeholderTextColor="#555570"
style={[s.input, { height: 70 }]}
multiline
/>
<TouchableOpacity
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
>
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
</Text>
<View style={{
width: 46, height: 26, borderRadius: 13, padding: 3,
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
}}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
</View>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={saveEdit} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
<Text style={s.modalBtnText}>Speichern</Text>
</TouchableOpacity>
</View>
{editing && editing.status !== 'ended' && (
<TouchableOpacity onPress={() => endProject(editing)} style={s.tertiaryBtn}>
<Text style={s.tertiaryBtnText}> Projekt beenden</Text>
</TouchableOpacity>
)}
{editing && (
<TouchableOpacity onPress={() => archiveProject(editing)} style={s.tertiaryBtn}>
<Text style={[s.tertiaryBtnText, { color: '#E55C5C' }]}>🗑 Archivieren</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
</View>
);
// Wenn als Modal genutzt
if (onClose) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
{body}
</Modal>
);
}
return body;
};
const s = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 14,
borderBottomWidth: 1,
borderColor: '#1E1E2E',
backgroundColor: '#080810',
},
headerBtn: { padding: 8, minWidth: 60 },
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderColor: '#1E1E2E',
},
rowActive: {
backgroundColor: 'rgba(52,199,89,0.08)',
borderLeftWidth: 3,
borderLeftColor: '#34C759',
},
rowHidden: { opacity: 0.55 },
eyeBtn: { paddingHorizontal: 8, paddingVertical: 6, marginLeft: 6 },
eyeIcon: { fontSize: 18 },
hiddenBadge: { color: '#B392F0', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(179,146,240,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
hiddenToggle: {
paddingHorizontal: 16, paddingVertical: 10,
borderBottomWidth: 1, borderColor: '#1E1E2E',
backgroundColor: '#0D0D18',
},
hiddenToggleText: { color: '#B392F0', fontSize: 12, fontWeight: '600' },
rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
activeBadge: { color: '#34C759', fontSize: 10, fontWeight: '800' },
statusBadge: { color: '#FFD60A', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(255,214,10,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
errorText: { color: '#FF6E6E', padding: 16, textAlign: 'center', fontSize: 13 },
emptyText: { color: '#555570', padding: 24, textAlign: 'center', fontSize: 13, lineHeight: 19 },
modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center', paddingHorizontal: 20,
},
modalCard: { backgroundColor: '#15151E', borderRadius: 12, padding: 18 },
modalTitle: { color: '#E0E0F0', fontSize: 18, fontWeight: '700', marginBottom: 14 },
input: {
backgroundColor: '#0A0A14', borderRadius: 6, color: '#E0E0F0',
paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, marginBottom: 8,
borderWidth: 1, borderColor: '#2A2A3E',
},
modalBtn: { flex: 1, alignItems: 'center', paddingVertical: 11, borderRadius: 6 },
modalBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' },
tertiaryBtn: { alignItems: 'center', paddingVertical: 10, marginTop: 8 },
tertiaryBtnText: { color: '#FFD60A', fontSize: 13, fontWeight: '600' },
});
export default ProjectsBrowser;
+13 -6
View File
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
<View style={styles.container}>
{hasPermission ? (
<>
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
CameraScreen-Props faelschlich als required (defaultProps fuellen
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
zu umgehen, ohne echten Code zu veraendern. */}
<CameraScreen
scanBarcode={true}
onReadCode={handleBarcodeScan}
showFrame={true}
frameColor="#0096FF"
laserColor="#0096FF"
colorForScannerFrame="#0096FF"
{...({
scanBarcode: true,
onReadCode: handleBarcodeScan,
showFrame: true,
frameColor: '#0096FF',
laserColor: '#0096FF',
} as any)}
/>
{/* Overlay oben */}
+657
View File
@@ -0,0 +1,657 @@
/**
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
* Logs und Loeschen.
*
* Eingesetzt von SettingsScreen → Sektion "Skills".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_ARIA = '#FFD60A';
const COL_STEFAN = '#0096FF';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const SkillBrowser: React.FC = () => {
const [items, setItems] = useState<Skill[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [detail, setDetail] = useState<Skill | null>(null);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listSkills()
.then(s => {
s.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(s);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const visible = items.filter(s => {
if (filter === 'active') return s.active;
if (filter === 'inactive') return !s.active;
return true;
});
const toggleActive = (s: Skill) => {
brainApi.updateSkill(s.name, { active: !s.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const renderItem = ({ item }: { item: Skill }) => {
const isAria = (item.author || '').toLowerCase() === 'aria';
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
return (
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
{item.setup_error ? (
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
Setup-Fehler: {item.setup_error}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={s => s.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
: '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{detail ? (
<SkillDetailModal
skill={detail}
onClose={() => setDetail(null)}
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
/>
) : null}
</View>
);
};
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
interface DetailProps {
skill: Skill;
onClose: () => void;
onReload: () => void;
}
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
const [argValues, setArgValues] = useState<Record<string, string>>({});
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState<{
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
} | null>(null);
const [logs, setLogs] = useState<any[] | null>(null);
const [loadingLogs, setLoadingLogs] = useState(false);
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
const [cfgSaving, setCfgSaving] = useState(false);
// P4: Versionen + Rollback
const [versions, setVersions] = useState<SkillVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const args = Array.isArray(skill.args) ? skill.args : [];
// Config + Versionen beim Mount laden
useEffect(() => {
brainApi.getSkillConfig(skill.name)
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); })
.catch(() => {});
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => setVersions([]))
.finally(() => setVersionsLoading(false));
}, [skill.name]);
const setArg = (name: string, value: string) =>
setArgValues(prev => ({ ...prev, [name]: value }));
const run = () => {
setRunning(true); setRunResult(null);
const argsObj: Record<string, any> = {};
for (const a of args) {
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
argsObj[a.name] = argValues[a.name];
}
}
brainApi.runSkill(skill.name, argsObj)
.then(r => setRunResult(r))
.catch(e => setRunResult({
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
}))
.finally(() => setRunning(false));
};
const loadLogs = () => {
setLoadingLogs(true);
brainApi.getSkillLogs(skill.name, 20)
.then(setLogs)
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
.finally(() => setLoadingLogs(false));
};
const remove = () => {
Alert.alert(
'Skill loeschen?',
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen',
style: 'destructive',
onPress: () => {
brainApi.deleteSkill(skill.name)
.then(() => { onReload(); onClose(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const saveConfig = () => {
// secret-Felder die als '***SET***' angezeigt sind und vom User NICHT
// angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur
// explizit getippte Werte; alles andere uebernehmen wir aus cfgValues.
const next: Record<string, any> = { ...cfgValues };
for (const f of cfgSchema) {
const draft = cfgDraft[f.name];
const isSecret = f.secret || f.type === 'password';
if (draft === undefined) continue;
if (isSecret && draft === '') continue; // leer = unveraendert
if (draft === '') { delete next[f.name]; continue; }
if (f.type === 'number') {
const n = Number(draft); next[f.name] = isNaN(n) ? draft : n;
} else if (f.type === 'boolean') {
next[f.name] = draft === 'true' || draft === '1';
} else {
next[f.name] = draft;
}
}
// Maskierte Werte (***SET***) niemals zurueckschreiben
for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k];
setCfgSaving(true);
brainApi.setSkillConfig(skill.name, next)
.then(() => {
// frisch laden um neuen masked-State zu zeigen
return brainApi.getSkillConfig(skill.name);
})
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); })
.catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)))
.finally(() => setCfgSaving(false));
};
const reloadVersions = () => {
setVersionsLoading(true);
brainApi.listSkillVersions(skill.name)
.then(setVersions)
.catch(() => {})
.finally(() => setVersionsLoading(false));
};
const doRollback = (versionId: string) => {
Alert.alert(
'Rollback?',
`Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Rollback', style: 'destructive',
onPress: () => {
brainApi.rollbackSkill(skill.name, versionId)
.then(r => {
Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`);
reloadVersions(); onReload();
})
.catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e)));
},
},
],
);
};
const removeVersion = (versionId: string) => {
Alert.alert(
'Version loeschen?',
`${versionId} dauerhaft entfernen?`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Loeschen', style: 'destructive',
onPress: () => {
brainApi.deleteSkillVersion(skill.name, versionId)
.then(reloadVersions)
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
</TouchableOpacity>
</View>
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
<Text style={s.label}>Beschreibung</Text>
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
<View style={s.metaBox}>
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
{skill.setup_error ? (
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
) : null}
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
) : null}
</View>
{/* Args-Inputs */}
{args.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
{args.map((a: any) => (
<View key={a.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{a.name}{a.required ? ' *' : ''} {a.description ? `${a.description}` : ''}
</Text>
<TextInput
style={s.input}
value={argValues[a.name] || ''}
onChangeText={(v) => setArg(a.name, v)}
placeholder={a.type || 'string'}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
))}
</>
) : null}
{/* Config-Schema-Form (P3) */}
{cfgSchema.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}> Konfiguration</Text>
{cfgSchema.map((f) => {
const isSecret = f.secret || f.type === 'password';
const cur = cfgValues[f.name];
const isSet = isSecret && cur === '***SET***';
const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert'
: (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string'));
const valStr = cfgDraft[f.name] !== undefined
? cfgDraft[f.name]
: (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : ''));
if (f.type === 'boolean') {
const bv = cfgDraft[f.name] !== undefined
? (cfgDraft[f.name] === 'true')
: (cur === true || cur === 'true');
return (
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
<View style={{flex: 1}}>
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
</View>
</View>
);
}
return (
<View key={f.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{f.label || f.name}{isSecret ? ' 🔒' : ''}
{f.description ? <Text style={{color: '#555570'}}> {f.description}</Text> : null}
</Text>
<TextInput
style={s.input}
value={valStr}
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
placeholder={placeholder}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={isSecret}
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
/>
</View>
);
})}
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
onPress={saveConfig}
disabled={cfgSaving}
>
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
</Text>
</TouchableOpacity>
</>
) : null}
{/* Versionen (P4) */}
{versions.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
{versions.map(v => (
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
<View style={{flex: 1}}>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
</View>
<TouchableOpacity onPress={() => doRollback(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
<Text style={{color: COL_ARIA, fontSize: 12}}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
</TouchableOpacity>
</View>
))}
</>
) : versionsLoading ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : null}
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
<TouchableOpacity
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
onPress={run}
disabled={!skill.active || running}
>
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
{running ? 'läuft...' : '▶ Ausführen'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
onPress={loadLogs}
>
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
</TouchableOpacity>
</View>
{!skill.active ? (
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
Skill ist deaktiviert toggle in der Liste zum Aktivieren.
</Text>
) : null}
{/* Run-Result */}
{runResult ? (
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
</Text>
{runResult.stdout ? (
<>
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
</>
) : null}
{runResult.stderr ? (
<>
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
</>
) : null}
</View>
) : null}
{/* Logs */}
{loadingLogs ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : logs ? (
<View style={{marginTop: 14}}>
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
{logs.length === 0 ? (
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
) : logs.map((log, idx) => (
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
</Text>
{log.stdout ? (
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
{String(log.stdout).slice(0, 300)}
</Text>
) : null}
</View>
))}
</View>
) : null}
<View style={{height: 30}} />
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
<Text style={{color: '#8888AA'}}>Schliessen</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// ── Styles ───────────────────────────────────────────────────────────
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
chip: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 12,
backgroundColor: '#1A1A2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
backgroundColor: '#1A1A2E',
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#0D0D1A',
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
err: {
color: '#FF6B6B',
padding: 12,
fontSize: 12,
},
modal: {
flex: 1,
backgroundColor: '#0D0D1A',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalTitle: {
color: '#E0E0F0',
fontSize: 16,
fontWeight: '700',
flex: 1,
marginRight: 12,
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 6,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default SkillBrowser;
+595
View File
@@ -0,0 +1,595 @@
/**
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
*
* Eingesetzt von SettingsScreen → Sektion "Trigger".
*
* Brain-API ueber brainApi (RVS-Brain-Proxy).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import brainApi, { Trigger } from '../services/brainApi';
import rvs from '../services/rvs';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
const COL_TIMER = '#0096FF';
const COL_WATCHER = '#FFD60A';
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (!t) return '—';
const diffSec = Math.floor((Date.now() - t) / 1000);
if (diffSec < 60) return `vor ${diffSec}s`;
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
return `vor ${Math.floor(diffSec / 86400)}d`;
}
export const TriggerBrowser: React.FC = () => {
const [items, setItems] = useState<Trigger[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
const [showNew, setShowNew] = useState(false);
const load = useCallback(() => {
setLoading(true); setErr(null);
brainApi.listTriggers()
.then(t => {
// Sortierung: aktive zuerst, dann nach Name
t.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return (a.name || '').localeCompare(b.name || '');
});
setItems(t);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// Auto-Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail-
// Fehler aus brainApi ewig an obwohl die Verbindung schon wieder da ist.
useEffect(() => {
const unsub = rvs.onStateChange((state) => {
if (state === 'connected') {
load();
}
});
return () => unsub();
}, [load]);
const visible = items.filter(t => {
if (filter === 'active') return t.active;
if (filter === 'inactive') return !t.active;
return true;
});
const toggleActive = (t: Trigger) => {
brainApi.updateTrigger(t.name, { active: !t.active })
.then(() => load())
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
};
const deleteTrigger = (t: Trigger) => {
Alert.alert(
'Trigger löschen?',
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => {
brainApi.deleteTrigger(t.name)
.then(() => { setEditTrigger(null); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
},
},
],
);
};
const renderItem = ({ item }: { item: Trigger }) => {
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
return (
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
<View style={{flex: 1, marginRight: 8}}>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
</View>
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
{item.type === 'watcher' && item.condition ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
{item.condition}
</Text>
) : null}
{item.type === 'timer' && item.fires_at ? (
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
</Text>
) : null}
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
</Text>
</View>
<Switch
value={item.active}
onValueChange={() => toggleActive(item)}
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
thumbColor="#E0E0F0"
/>
</TouchableOpacity>
);
};
return (
<View style={{flex: 1}}>
{/* Filter-Leiste + Reload + Neu */}
<View style={s.toolbar}>
{(['all', 'active', 'inactive'] as const).map(f => (
<TouchableOpacity
key={f}
style={[s.chip, filter === f && s.chipActive]}
onPress={() => setFilter(f)}
>
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
</Text>
</TouchableOpacity>
))}
<View style={{flex: 1}} />
<TouchableOpacity onPress={load} style={s.iconBtn}>
<Text style={{fontSize: 16}}>{'↻'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
</TouchableOpacity>
</View>
{err ? <Text style={s.err}>{err}</Text> : null}
{loading && items.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
) : (
<FlatList
data={visible}
keyExtractor={t => t.name}
renderItem={renderItem}
nestedScrollEnabled={true}
ListEmptyComponent={
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
</Text>
}
contentContainerStyle={{paddingBottom: 20}}
/>
)}
{editTrigger ? (
<TriggerEditModal
trigger={editTrigger}
onClose={() => setEditTrigger(null)}
onSaved={() => { setEditTrigger(null); load(); }}
onDelete={() => deleteTrigger(editTrigger)}
/>
) : null}
{showNew ? (
<TriggerNewModal
onClose={() => setShowNew(false)}
onCreated={() => { setShowNew(false); load(); }}
/>
) : null}
</View>
);
};
// ── Edit-Modal ─────────────────────────────────────────────────────────
interface EditProps {
trigger: Trigger;
onClose: () => void;
onSaved: () => void;
onDelete: () => void;
}
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
const [message, setMessage] = useState(trigger.message || '');
const [condition, setCondition] = useState(trigger.condition || '');
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
const patch: any = { message };
if (trigger.type === 'watcher') {
patch.condition = condition;
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
patch.throttle_sec = parseInt(throttle, 10) || 3600;
} else if (trigger.type === 'timer') {
patch.fires_at = firesAt;
}
brainApi.updateTrigger(trigger.name, patch)
.then(onSaved)
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
placeholderTextColor="#555570"
/>
{trigger.type === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
</>
)}
<View style={s.metaBox}>
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
</View>
</ScrollView>
<View style={s.modalFooter}>
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
</TouchableOpacity>
<View style={{flex: 1}} />
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// ── Neu-Modal ──────────────────────────────────────────────────────────
interface NewProps {
onClose: () => void;
onCreated: () => void;
}
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [condition, setCondition] = useState('');
const [firesAt, setFiresAt] = useState('');
const [checkInterval, setCheckInterval] = useState('300');
const [throttle, setThrottle] = useState('3600');
const [saving, setSaving] = useState(false);
const create = () => {
if (!name.trim() || !message.trim()) {
Alert.alert('Name und Nachricht erforderlich');
return;
}
setSaving(true);
const promise = ttype === 'timer'
? brainApi.createTimer({
name: name.trim(),
fires_at: firesAt.trim(),
message: message.trim(),
})
: brainApi.createWatcher({
name: name.trim(),
condition: condition.trim(),
message: message.trim(),
check_interval_sec: parseInt(checkInterval, 10) || 300,
throttle_sec: parseInt(throttle, 10) || 3600,
});
promise
.then(onCreated)
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
.finally(() => setSaving(false));
};
return (
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
<View style={s.modalBg}>
<View style={s.modal}>
<View style={s.modalHeader}>
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
<TouchableOpacity onPress={onClose}>
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={{padding: 14}} nestedScrollEnabled>
<Text style={s.label}>Typ</Text>
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
{(['watcher', 'timer'] as const).map(t => (
<TouchableOpacity
key={t}
onPress={() => setTtype(t)}
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
>
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.label}>Name (kebab-case)</Text>
<TextInput
style={s.input}
value={name}
onChangeText={setName}
placeholder="z.B. drk-kreyenbrueck-warnung"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.label}>Nachricht</Text>
<TextInput
style={s.input}
value={message}
onChangeText={setMessage}
multiline
placeholder="Was soll ARIA sagen?"
placeholderTextColor="#555570"
/>
{ttype === 'watcher' ? (
<>
<Text style={s.label}>Condition</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={condition}
onChangeText={setCondition}
placeholder="z.B. entered_near(53.0, 8.5, 300)"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
</Text>
<View style={{flexDirection: 'row', gap: 8}}>
<View style={{flex: 1}}>
<Text style={s.label}>Check-Intervall (s)</Text>
<TextInput
style={s.input}
value={checkInterval}
onChangeText={setCheckInterval}
keyboardType="number-pad"
/>
</View>
<View style={{flex: 1}}>
<Text style={s.label}>Throttle (s)</Text>
<TextInput
style={s.input}
value={throttle}
onChangeText={setThrottle}
keyboardType="number-pad"
/>
</View>
</View>
</>
) : (
<>
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
<TextInput
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
value={firesAt}
onChangeText={setFiresAt}
placeholder="2026-05-15T20:00:00+00:00"
placeholderTextColor="#555570"
autoCapitalize="none"
/>
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
</>
)}
</ScrollView>
<View style={s.modalFooter}>
<View style={{flex: 1}} />
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
toolbar: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
chipActive: {
backgroundColor: '#FFD60A',
},
iconBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#1E1E2E',
},
err: {
color: '#FF3B30',
padding: 12,
fontSize: 12,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#1A1A2E',
borderRadius: 8,
marginBottom: 6,
},
modalBg: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
modal: {
backgroundColor: '#0D0D1A',
borderRadius: 12,
width: '100%',
maxWidth: 600,
maxHeight: '90%',
borderWidth: 1,
borderColor: '#1E1E2E',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
modalFooter: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
gap: 8,
},
label: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#1A1A2E',
borderWidth: 1,
borderColor: '#1E1E2E',
borderRadius: 6,
color: '#E0E0F0',
padding: 10,
fontSize: 14,
marginBottom: 8,
},
hint: {
color: '#555570',
fontSize: 11,
fontStyle: 'italic',
marginTop: -4,
marginBottom: 10,
},
metaBox: {
backgroundColor: '#1A1A2E',
borderRadius: 6,
padding: 10,
marginTop: 10,
gap: 4,
},
meta: {
color: '#8888AA',
fontSize: 12,
},
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: 'transparent',
},
});
export default TriggerBrowser;
+47
View File
@@ -0,0 +1,47 @@
/**
* ViewModeToggle — kleiner Header-Button zum Umschalten zwischen Kompakt-
* Ansicht (klassischer Chat) und Cockpit (Kachel-Desktop).
*
* Sitzt rechts im Navigations-Header ("ARIA Cockpit"), kollidiert also mit
* nichts in der Chat-Ansicht. Zeigt das Ziel des naechsten Taps.
*/
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
import viewMode, { ViewModeValue } from '../services/viewMode';
const ViewModeToggle: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
useEffect(() => viewMode.subscribe(setMode), []);
const isCockpit = mode === 'cockpit';
return (
<TouchableOpacity
onPress={() => viewMode.toggle()}
style={[styles.pill, isCockpit && styles.pillActive]}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.75}
>
<Text style={[styles.text, isCockpit && styles.textActive]}>
{isCockpit ? '⧉ Cockpit' : '⧉ Kompakt'}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
pill: {
marginRight: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: '#1E1E2E',
backgroundColor: '#12122A',
},
pillActive: { borderColor: '#0096FF', backgroundColor: '#0A1F33' },
text: { color: '#9090B0', fontSize: 13, fontWeight: '700' },
textActive: { color: '#0096FF' },
});
export default ViewModeToggle;
+60 -90
View File
@@ -1,12 +1,19 @@
/**
* VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf
* VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante).
*
* Zwei Modi:
* 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden
* 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille
* (auch genutzt fuer Wake-Word-getriggerte Aufnahme)
* Push-to-Talk gibt's nicht mehr. Tap startet Streaming-Aufnahme an die
* Whisper-Bridge. Tap nochmal sendet stt_stream_end → Whisper liefert den
* finalen Text → aria-bridge forwardet direkt an Brain. Keine dB/VAD-
* Stille-Erkennung mehr — Whisper hoert auf semantische Stille (kein
* neuer Text mehr).
*
* Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme.
* Diese Komponente ist absichtlich "dumm": sie kapselt nur den
* Tap-Lifecycle + die Animation. Recording-Optionen (voice/speed/
* location/interrupted) baut ChatScreen, die User-Bubble ebenfalls.
*
* Visuelles Feedback: pulsierende Animation + Dauer + dB-Pegel via
* audioService.onMeterUpdate (das macht audio.ts noch fuer alte Records;
* neu kommt der Pegel via NativeEventEmitter (PcmStreamMeter) — folgt).
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
@@ -17,25 +24,28 @@ import {
StyleSheet,
Easing,
TouchableOpacity,
Pressable,
} from 'react-native';
import audioService, { RecordingResult } from '../services/audio';
import audioService, { RecordingState } from '../services/audio';
// --- Typen ---
interface VoiceButtonProps {
/** Wird aufgerufen wenn die Aufnahme fertig ist */
onRecordingComplete: (result: RecordingResult) => void;
/** User hat getippt — ChatScreen soll Bubble bauen + startStreamingRecording.
* Returns true wenn die Aufnahme tatsaechlich gestartet ist. */
onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>;
/** Button deaktivieren */
disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt Indikator) */
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
wakeWordActive?: boolean;
}
// --- Komponente ---
const VoiceButton: React.FC<VoiceButtonProps> = ({
onRecordingComplete,
onTapStart,
onTapStop,
disabled = false,
wakeWordActive = false,
}) => {
@@ -44,7 +54,21 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const [meterDb, setMeterDb] = useState(-160);
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const isLongPress = useRef(false);
// State via audioService.onStateChange spiegeln — der Service ist die
// Quelle der Wahrheit (Streaming-Session, Wake-Word-Multi-Turn, etc.
// koennen den Recording-State von extern aendern). isStreamingRecording
// ist auch true wenn die Wake-Word-Konversation gerade aufzeichnet —
// dann zeigt der Button "stop"-Symbol, und Tap stoppt die laufende
// Aufnahme (egal ob via Wake-Word oder Knopf gestartet).
useEffect(() => {
const unsub = audioService.onStateChange((next: RecordingState) => {
setIsRecording(next === 'recording');
});
// Initial-State synchronisieren
setIsRecording(audioService.getRecordingState() === 'recording');
return unsub;
}, []);
// Puls-Animation starten/stoppen
useEffect(() => {
@@ -72,14 +96,13 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [isRecording, pulseAnim]);
// Aufnahmedauer zaehlen + Metering
// Aufnahmedauer zaehlen + Metering (Pegel-Bar)
useEffect(() => {
if (isRecording) {
setDurationMs(0);
durationTimer.current = setInterval(() => {
setDurationMs(prev => prev + 100);
}, 100);
const unsubMeter = audioService.onMeterUpdate(setMeterDb);
return () => {
unsubMeter();
@@ -90,78 +113,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
clearInterval(durationTimer.current);
durationTimer.current = null;
}
setMeterDb(-160);
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop
useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => {
if (!isRecording) return;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 500) {
onRecordingComplete(result);
// Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = useCallback(async () => {
if (disabled || tapBusy.current) return;
tapBusy.current = true;
try {
// Service-State fragen statt React-State (Closure koennte stale sein)
const svcState = audioService.getRecordingState();
if (svcState === 'recording') {
await onTapStop();
} else if (svcState === 'idle') {
await onTapStart();
}
});
return unsubSilence;
}, [isRecording, onRecordingComplete]);
// Auto-Start fuer Wake Word (extern getriggert)
const startAutoRecording = useCallback(async () => {
if (disabled || isRecording) return;
const started = await audioService.startRecording(true); // autoStop = true
if (started) {
isLongPress.current = false;
setIsRecording(true);
// 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen
} finally {
tapBusy.current = false;
}
}, [disabled, isRecording]);
// Push-to-Talk: Lang druecken
const handlePressIn = async () => {
if (disabled || isRecording) return;
isLongPress.current = true;
const started = await audioService.startRecording(false); // kein autoStop
if (started) {
setIsRecording(true);
}
};
const handlePressOut = async () => {
if (!isRecording || !isLongPress.current) return;
isLongPress.current = false;
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
};
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
const handleTap = async () => {
if (disabled) return;
if (isRecording) {
// Aufnahme manuell stoppen
setIsRecording(false);
const result = await audioService.stopRecording();
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
} else {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
isLongPress.current = false;
setIsRecording(true);
}
}
};
// Expose startAutoRecording via ref fuer Wake Word
React.useImperativeHandle(
React.createRef(),
() => ({ startAutoRecording }),
[startAutoRecording],
);
}, [disabled, onTapStart, onTapStop]);
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
@@ -169,7 +142,11 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
return `${seconds}.${tenths}s`;
};
// Meter-Visualisierung (0-1 Skala)
// Meter-Visualisierung (-60..0 dB → 0..1). Bei Streaming-Mode liefert
// audio.ts (noch) keinen Pegel, also bleibt der Balken leer — wird in
// einem Folge-Commit nachgerueckt (PcmStreamRecorder-Module muss dafuer
// einen RMS-Wert mit-emitten). Tut der Streaming-Funktion keinen Abbruch,
// ist reines UI-Beiwerk.
const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60));
return (
@@ -183,10 +160,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
isRecording && styles.buttonOuterRecording,
{ transform: [{ scale: pulseAnim }] },
]}
onStartShouldSetResponder={() => true}
onResponderGrant={handlePressIn}
onResponderRelease={handlePressOut}
onResponderTerminate={handlePressOut}
>
<TouchableOpacity
activeOpacity={0.8}
@@ -207,9 +180,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
);
};
// Expose startAutoRecording fuer externe Aufrufe (Wake Word)
export type VoiceButtonHandle = { startAutoRecording: () => Promise<void> };
// --- Styles ---
const styles = StyleSheet.create({
+362
View File
@@ -0,0 +1,362 @@
/**
* VoiceCloneModal — Eigene Stimme aufnehmen und an XTTS uploaden.
*
* Flow:
* - Modal zeigt Vorlesetext (>30s Lesedauer) + Aufnahme-Button
* - Bei Aufnahme: max 30s, Fortschrittsbalken, Countdown
* - Bei Stop: Name abfragen, dann als voice_upload ueber RVS schicken
* - XTTS-Bridge speichert /voices/<name>.wav, antwortet mit xtts_voice_saved
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
ScrollView,
ActivityIndicator,
TextInput,
} from 'react-native';
import audioService from '../services/audio';
import rvs from '../services/rvs';
interface Props {
visible: boolean;
onClose: () => void;
}
const SAMPLE_TEXT = `Das ist meine eigene Stimme fuer ARIA. Ich lese jetzt einen laengeren Absatz laut vor, damit das Voice-Cloning eine gute Grundlage hat. Guten Tag, ich heisse Stefan und baue gerade mit grosser Begeisterung an meinem persoenlichen KI-Assistenten. Wir automatisieren Infrastruktur, managen Sessions und spielen mit Sprachsynthese. Die letzten Jahre habe ich viel gelernt, vor allem dass Geduld genauso wichtig ist wie Neugier. Hoert sich das jetzt an wie ich selbst? Wenn alles klappt, spricht ARIA bald mit dieser Stimme.`;
const MAX_DURATION_MS = 30000;
const TARGET_DURATION_MS = 15000;
const VoiceCloneModal: React.FC<Props> = ({ visible, onClose }) => {
const [recording, setRecording] = useState(false);
const [durationMs, setDurationMs] = useState(0);
const [voiceName, setVoiceName] = useState('');
const [processing, setProcessing] = useState(false);
const [recordingPath, setRecordingPath] = useState('');
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
// Zustand zuruecksetzen wenn Modal schliesst/oeffnet
useEffect(() => {
if (!visible) {
setRecording(false);
setDurationMs(0);
setVoiceName('');
setProcessing(false);
setRecordingPath('');
if (timerRef.current) clearInterval(timerRef.current);
}
}, [visible]);
// Cleanup bei Unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (recording) audioService.stopRecording().catch(() => {});
};
}, [recording]);
const startRecording = useCallback(async () => {
// Frische Aufnahme
setDurationMs(0);
setRecordingPath('');
const ok = await audioService.startRecording(false);
if (!ok) {
Alert.alert('Fehler', 'Aufnahme konnte nicht gestartet werden (Mikrofon-Berechtigung?)');
return;
}
setRecording(true);
startTimeRef.current = Date.now();
timerRef.current = setInterval(async () => {
const elapsed = Date.now() - startTimeRef.current;
setDurationMs(elapsed);
if (elapsed >= MAX_DURATION_MS) {
await stopRecording();
}
}, 100);
}, []);
const stopRecording = useCallback(async () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (!recording) return;
const result = await audioService.stopRecording();
setRecording(false);
if (!result) {
Alert.alert('Keine Sprache erkannt', 'Versuch es bitte nochmal — sprich bis der Timer mindestens 10 Sekunden anzeigt.');
setDurationMs(0);
return;
}
// Temp-Datei wurde schon geloescht (stopRecording cleaned up).
// Wir brauchen aber base64 aus result direkt fuers Upload.
// result.base64 ist bereits da.
setRecordingPath(result.base64);
}, [recording]);
const uploadVoice = useCallback(async () => {
const name = voiceName.trim();
if (!name) {
Alert.alert('Name fehlt', 'Bitte gib der Stimme einen Namen (nur Buchstaben, Zahlen, _ und -).');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
Alert.alert('Ungueltiger Name', 'Nur Buchstaben, Zahlen, _ und - erlaubt.');
return;
}
if (!recordingPath) {
Alert.alert('Keine Aufnahme', 'Bitte zuerst aufnehmen.');
return;
}
setProcessing(true);
try {
// voice_upload erwartet samples als Array mit base64 (aus Diagnostic-Format kopiert)
rvs.send('voice_upload' as any, {
name,
samples: [{ base64: recordingPath }],
});
Alert.alert('Hochgeladen', `Stimme "${name}" wird vom XTTS-Server verarbeitet. Nach ein paar Sekunden in der Liste verfuegbar.`);
onClose();
} catch (err: any) {
Alert.alert('Fehler', err.message);
} finally {
setProcessing(false);
}
}, [voiceName, recordingPath, onClose]);
const progress = Math.min(durationMs / MAX_DURATION_MS, 1);
const sec = Math.floor(durationMs / 1000);
const enoughRecorded = durationMs >= TARGET_DURATION_MS;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Eigene Stimme aufnehmen</Text>
<TouchableOpacity onPress={onClose}>
<Text style={styles.closeX}>{'\u2715'}</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content} contentContainerStyle={{padding: 16}}>
<Text style={styles.hint}>
Lies den Text laut und deutlich vor. Maximal 30 Sekunden. Je mehr du sprichst
(ziel: bis zum Ende des Textes, ca. 20-30s), desto besser wird die geklonte
Stimme.
</Text>
<View style={styles.sampleTextBox}>
<Text style={styles.sampleText}>{SAMPLE_TEXT}</Text>
</View>
{/* Timer + Fortschritt */}
<View style={{marginTop: 20, alignItems: 'center'}}>
<Text style={[styles.timer, recording && styles.timerActive]}>
{sec.toString().padStart(2, '0')} / 30 s
</Text>
<View style={styles.progressBar}>
<View style={[styles.progressFill, {width: `${progress * 100}%`, backgroundColor: recording ? '#FF3B30' : '#0096FF'}]} />
</View>
</View>
{/* Aufnahme-Button */}
{!recordingPath && (
<TouchableOpacity
style={[styles.recordBtn, recording && styles.recordBtnActive]}
onPress={recording ? stopRecording : startRecording}
>
<Text style={styles.recordIcon}>{recording ? '\u25A0' : '\u25CF'}</Text>
<Text style={styles.recordLabel}>{recording ? 'Stop' : 'Aufnahme starten'}</Text>
</TouchableOpacity>
)}
{/* Nach Aufnahme: Name + Upload */}
{recordingPath && (
<View style={{marginTop: 20}}>
<Text style={styles.hint}>
Aufnahme ({sec}s) fertig. Vergib einen Namen und lade hoch.
</Text>
<TextInput
style={styles.nameInput}
value={voiceName}
onChangeText={setVoiceName}
placeholder="z.B. stefan"
placeholderTextColor="#555570"
autoCapitalize="none"
autoCorrect={false}
/>
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
<TouchableOpacity
style={[styles.secondaryBtn, {flex: 1}]}
onPress={() => { setRecordingPath(''); setDurationMs(0); }}
>
<Text style={styles.secondaryBtnText}>Nochmal aufnehmen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryBtn, {flex: 1}]}
onPress={uploadVoice}
disabled={processing}
>
{processing
? <ActivityIndicator color="#fff" />
: <Text style={styles.primaryBtnText}>Hochladen</Text>
}
</TouchableOpacity>
</View>
</View>
)}
{recording && !enoughRecorded && (
<Text style={[styles.hint, {marginTop: 12, color: '#FFD60A', textAlign: 'center'}]}>
Bitte weiter lesen mindestens 15 Sekunden
</Text>
)}
{recording && enoughRecorded && (
<Text style={[styles.hint, {marginTop: 12, color: '#34C759', textAlign: 'center'}]}>
Genug Audio fuer eine gute Clonung. Du kannst stoppen.
</Text>
)}
</ScrollView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0D0D1A',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 48,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#1E1E2E',
},
title: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
},
closeX: {
color: '#8888AA',
fontSize: 24,
paddingHorizontal: 8,
},
content: {
flex: 1,
},
hint: {
color: '#8888AA',
fontSize: 13,
lineHeight: 20,
},
sampleTextBox: {
marginTop: 12,
padding: 14,
backgroundColor: '#12122A',
borderRadius: 10,
borderWidth: 1,
borderColor: '#1E1E2E',
},
sampleText: {
color: '#E0E0F0',
fontSize: 15,
lineHeight: 24,
},
timer: {
color: '#666680',
fontSize: 42,
fontWeight: '700',
fontVariant: ['tabular-nums'],
},
timerActive: {
color: '#FF3B30',
},
progressBar: {
marginTop: 8,
width: '100%',
height: 8,
backgroundColor: '#1E1E2E',
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
},
recordBtn: {
marginTop: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
backgroundColor: '#1E1E2E',
borderRadius: 12,
padding: 18,
borderWidth: 2,
borderColor: '#34C759',
},
recordBtnActive: {
borderColor: '#FF3B30',
backgroundColor: 'rgba(255,59,48,0.15)',
},
recordIcon: {
color: '#FF3B30',
fontSize: 24,
fontWeight: '700',
},
recordLabel: {
color: '#FFFFFF',
fontSize: 17,
fontWeight: '600',
},
nameInput: {
marginTop: 10,
backgroundColor: '#1E1E2E',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
color: '#FFFFFF',
fontSize: 15,
borderWidth: 1,
borderColor: '#2A2A3E',
},
primaryBtn: {
backgroundColor: '#0096FF',
borderRadius: 10,
padding: 14,
alignItems: 'center',
},
primaryBtnText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '700',
},
secondaryBtn: {
backgroundColor: '#1E1E2E',
borderRadius: 10,
padding: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: '#2A2A3E',
},
secondaryBtnText: {
color: '#8888AA',
fontSize: 14,
fontWeight: '600',
},
});
export default VoiceCloneModal;
@@ -0,0 +1,426 @@
/**
* Voice-ID Enrollment + Status — App-seitig.
*
* User nimmt 5-7 Samples (je 4s) seiner Stimme auf, App schickt sie an
* die whisper-bridge via RVS (voice_id_enroll_request). Bridge berechnet
* SpeechBrain-ECAPA-Embeddings, mittelt sie zu einem Fingerprint, speichert
* /voice-id/fingerprint.json.
*
* Verwendung: in SettingsScreen für Section 'voice_id' eingebunden.
* Holt Status bei Mount + nach jedem Enroll/Delete neu ab.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
ToastAndroid,
TouchableOpacity,
View,
} from 'react-native';
import audioService from '../services/audio';
import rvs from '../services/rvs';
const SAMPLE_DURATION_MS = 4000; // Pro Sample 4s aufnehmen
const SAMPLES_REQUIRED = 5; // Mindest-Sampleanzahl fuer Save
type Sample = {
base64: string;
durationMs: number;
};
type Status =
| { state: 'loading' }
| { state: 'unenrolled' }
| { state: 'enrolled'; sampleCount: number; durations: number[]; updatedAt: number; dim: number }
| { state: 'error'; message: string };
function _newReqId(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
}
export const VoiceIdEnrollment: React.FC = () => {
const [status, setStatus] = useState<Status>({ state: 'loading' });
const [samples, setSamples] = useState<Sample[]>([]);
const [recording, setRecording] = useState(false);
const [recordCountdown, setRecordCountdown] = useState(0);
const [enrollPending, setEnrollPending] = useState(false);
const [pendingReqId, setPendingReqId] = useState<string | null>(null);
// Status laden
const refreshStatus = useCallback(() => {
setStatus({ state: 'loading' });
const reqId = _newReqId('vid');
setPendingReqId(reqId);
rvs.send('voice_id_status_request' as any, { requestId: reqId });
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
// RVS-Antworten verarbeiten
useEffect(() => {
const unsub = rvs.onMessage((msg: any) => {
if (!msg) return;
const p = msg.payload || {};
if (msg.type === 'voice_id_status_response') {
if (p.ok === false) {
setStatus({ state: 'error', message: p.error || 'Whisper-Bridge nicht erreichbar' });
return;
}
if (p.enrolled) {
setStatus({
state: 'enrolled',
sampleCount: p.sample_count || 0,
durations: p.sample_durations_s || [],
updatedAt: p.updated_at || 0,
dim: p.embedding_dim || 0,
});
} else {
setStatus({ state: 'unenrolled' });
}
} else if (msg.type === 'voice_id_enroll_response') {
setEnrollPending(false);
if (p.ok === false) {
Alert.alert('Enrollment fehlgeschlagen', p.error || 'Unbekannter Fehler');
return;
}
const rejected = (p.rejected || []).length;
ToastAndroid.show(
`✓ Stimme gespeichert (${p.sample_count} Samples${rejected ? `, ${rejected} verworfen` : ''})`,
ToastAndroid.LONG,
);
setSamples([]);
refreshStatus();
} else if (msg.type === 'voice_id_delete_response') {
ToastAndroid.show(p.removed ? '✓ Stimme gelöscht' : 'Es war keine gespeichert', ToastAndroid.SHORT);
refreshStatus();
}
});
return () => unsub();
}, [refreshStatus]);
// Ein Sample aufnehmen — fest 4s, dann auto-stop
const recordSample = useCallback(async () => {
if (recording || enrollPending) return;
setRecording(true);
setRecordCountdown(SAMPLE_DURATION_MS / 1000);
try {
const ok = await audioService.startRecording(false);
if (!ok) {
ToastAndroid.show('Aufnahme konnte nicht gestartet werden', ToastAndroid.LONG);
setRecording(false);
setRecordCountdown(0);
return;
}
// Countdown-Timer (rein UI)
const tickInterval = setInterval(() => {
setRecordCountdown(c => Math.max(0, c - 1));
}, 1000);
// Auto-Stop nach festen 4s
await new Promise(r => setTimeout(r, SAMPLE_DURATION_MS));
clearInterval(tickInterval);
const result = await audioService.stopRecording();
setRecordCountdown(0);
setRecording(false);
if (!result || !result.base64) {
ToastAndroid.show('Aufnahme leer — nochmal probieren', ToastAndroid.LONG);
return;
}
setSamples(prev => [...prev, { base64: result.base64, durationMs: result.durationMs }]);
} catch (err: any) {
console.warn('[VoiceId] recordSample:', err);
try { await audioService.cancelRecording(); } catch {}
setRecording(false);
setRecordCountdown(0);
ToastAndroid.show('Aufnahmefehler: ' + (err?.message || err), ToastAndroid.LONG);
}
}, [recording, enrollPending]);
const removeSample = useCallback((idx: number) => {
setSamples(prev => prev.filter((_, i) => i !== idx));
}, []);
const sendEnrollment = useCallback(() => {
if (samples.length < SAMPLES_REQUIRED) {
Alert.alert('Noch nicht genug',
`Bitte mindestens ${SAMPLES_REQUIRED} Samples aufnehmen — aktuell ${samples.length}.`);
return;
}
if (enrollPending) return;
setEnrollPending(true);
const reqId = _newReqId('videnroll');
rvs.send('voice_id_enroll_request' as any, {
requestId: reqId,
samples: samples.map(s => s.base64),
});
// Sicherheits-Timeout: wenn nach 60s nichts kommt, freigeben
setTimeout(() => {
setEnrollPending(prev => {
if (prev) {
ToastAndroid.show('Enrollment-Timeout — bitte erneut versuchen', ToastAndroid.LONG);
}
return false;
});
}, 60_000);
}, [samples, enrollPending]);
const deleteFingerprint = useCallback(() => {
Alert.alert(
'Stimme löschen?',
'Danach muss ARIA neu enrolled werden, sonst greift Speaker-ID-Filter nicht.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen', style: 'destructive', onPress: () => {
const reqId = _newReqId('viddel');
rvs.send('voice_id_delete_request' as any, { requestId: reqId });
},
},
],
);
}, []);
// ── Render ──────────────────────────────────────────────
return (
<ScrollView contentContainerStyle={{ paddingBottom: 30 }}>
<Text style={s.intro}>
ARIA erkennt deine Stimme an einem Fingerprint (SpeechBrain ECAPA-TDNN, 192 Dimensionen).
Andere Sprecher (TV, Hintergrund, andere Personen) werden gefiltert keine Brain-Calls,
keine Tokens. {'\n\n'}
Sprich {SAMPLES_REQUIRED} Mal je {SAMPLE_DURATION_MS / 1000}s ganz normal verschiedene
Sätze, ruhige Umgebung empfohlen.
</Text>
{/* Status-Karte */}
<View style={s.card}>
<Text style={s.cardLabel}>Status</Text>
{status.state === 'loading' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator color="#0096FF" />
<Text style={s.statusText}>Wird abgefragt...</Text>
</View>
)}
{status.state === 'unenrolled' && (
<Text style={[s.statusText, { color: '#FFD60A' }]}> Nicht enrolled Stimme einrichten </Text>
)}
{status.state === 'enrolled' && (
<>
<Text style={[s.statusText, { color: '#34C759' }]}>
Enrolled {status.sampleCount} Samples
({status.durations.reduce((a, b) => a + b, 0).toFixed(1)}s gesamt)
</Text>
<Text style={s.statusSub}>
Aktualisiert {new Date(status.updatedAt * 1000).toLocaleString('de-DE')} · dim={status.dim}
</Text>
</>
)}
{status.state === 'error' && (
<Text style={[s.statusText, { color: '#FF6E6E' }]}> {status.message}</Text>
)}
</View>
{/* Aufnahme-Bereich */}
<View style={s.card}>
<Text style={s.cardLabel}>Samples ({samples.length}/{SAMPLES_REQUIRED})</Text>
{samples.length === 0 && !recording && (
<Text style={s.hint}>Tipp: sprich klare normale Sätze, je 3-4 Sekunden Audio.</Text>
)}
{samples.map((sample, idx) => (
<View key={idx} style={s.sampleRow}>
<Text style={s.sampleText}>
Sample {idx + 1} · {(sample.durationMs / 1000).toFixed(1)}s
</Text>
<TouchableOpacity onPress={() => removeSample(idx)} disabled={enrollPending}>
<Text style={{ color: '#FF6E6E', fontSize: 18 }}></Text>
</TouchableOpacity>
</View>
))}
<TouchableOpacity
onPress={recordSample}
disabled={recording || enrollPending}
style={[s.recordBtn, (recording || enrollPending) && { opacity: 0.5 }]}
>
{recording ? (
<>
<ActivityIndicator color="#fff" />
<Text style={s.recordBtnText}>Aufnahme läuft {recordCountdown}s</Text>
</>
) : (
<Text style={s.recordBtnText}> Sample {samples.length + 1} aufnehmen</Text>
)}
</TouchableOpacity>
{samples.length > 0 && !recording && (
<TouchableOpacity
onPress={() => setSamples([])}
disabled={enrollPending}
style={s.resetBtn}
>
<Text style={s.resetBtnText}>Alle verwerfen</Text>
</TouchableOpacity>
)}
</View>
{/* Aktionen */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<TouchableOpacity
onPress={sendEnrollment}
disabled={samples.length < SAMPLES_REQUIRED || enrollPending}
style={[
s.primaryBtn,
(samples.length < SAMPLES_REQUIRED || enrollPending) && { opacity: 0.4 },
]}
>
{enrollPending ? (
<>
<ActivityIndicator color="#fff" />
<Text style={s.primaryBtnText}>Wird verarbeitet</Text>
</>
) : (
<Text style={s.primaryBtnText}>
Speichern ({samples.length}/{SAMPLES_REQUIRED})
</Text>
)}
</TouchableOpacity>
</View>
{/* Verwaltung */}
{status.state === 'enrolled' && (
<View style={[s.card, { marginTop: 20 }]}>
<Text style={s.cardLabel}>Verwaltung</Text>
<TouchableOpacity onPress={refreshStatus} style={s.secondaryBtn}>
<Text style={s.secondaryBtnText}>🔄 Status aktualisieren</Text>
</TouchableOpacity>
<TouchableOpacity onPress={deleteFingerprint} style={s.dangerBtn}>
<Text style={s.dangerBtnText}>🗑 Fingerprint löschen (Re-Enrollment nötig)</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
};
const s = StyleSheet.create({
intro: {
color: '#8888AA',
fontSize: 13,
lineHeight: 19,
marginBottom: 16,
paddingHorizontal: 4,
},
card: {
backgroundColor: 'rgba(30,30,46,0.6)',
borderRadius: 8,
padding: 14,
marginBottom: 10,
},
cardLabel: {
color: '#8888AA',
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
statusText: {
color: '#E0E0F0',
fontSize: 14,
fontWeight: '600',
},
statusSub: {
color: '#555570',
fontSize: 11,
marginTop: 4,
},
hint: {
color: '#555570',
fontSize: 12,
fontStyle: 'italic',
marginBottom: 8,
},
sampleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 6,
borderBottomWidth: 1,
borderColor: '#2A2A3E',
},
sampleText: {
color: '#E0E0F0',
fontSize: 13,
},
recordBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#E55C5C',
borderRadius: 8,
paddingVertical: 14,
marginTop: 12,
},
recordBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
},
resetBtn: {
alignItems: 'center',
paddingVertical: 8,
marginTop: 6,
},
resetBtnText: {
color: '#FFD60A',
fontSize: 12,
},
primaryBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#34C759',
borderRadius: 8,
paddingVertical: 14,
},
primaryBtnText: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
},
secondaryBtn: {
backgroundColor: 'rgba(0,150,255,0.15)',
borderRadius: 6,
paddingVertical: 10,
alignItems: 'center',
marginTop: 6,
},
secondaryBtnText: {
color: '#0096FF',
fontSize: 13,
fontWeight: '600',
},
dangerBtn: {
backgroundColor: 'rgba(229,92,92,0.15)',
borderRadius: 6,
paddingVertical: 10,
alignItems: 'center',
marginTop: 6,
},
dangerBtnText: {
color: '#E55C5C',
fontSize: 13,
fontWeight: '600',
},
});
export default VoiceIdEnrollment;
+224
View File
@@ -0,0 +1,224 @@
/**
* ZoomableImage — Pinch-to-Zoom + Pan fuers Vollbild-Modal.
*
* Reine RN-Implementation, ohne react-native-gesture-handler.
*
* - 2 Finger: Pinch (Zoom 1x..5x) + simultaner Pan via Focal-Punkt
* - 1 Finger: Pan wenn schon gezoomt (>1.02x)
* - Doppel-Tap (<300ms zw. zwei Single-Taps): Toggle 1x ↔ 2.5x
*
* Implementierungs-Hinweise zur alten Version (warum's nicht ging):
* - `gestureState.numberActiveTouches` ist nicht zuverlaessig direkt
* nach onPanResponderGrant. Wir lesen Finger-Anzahl jetzt
* ausschliesslich aus `e.nativeEvent.touches.length`.
* - Beim Wechsel von 2 → 1 Fingern bleib die Pinch-Referenz haengen.
* Jetzt: bei jedem Finger-Wechsel re-snapshotten wir die Geste.
* - Animated.Image bekommt jetzt pointerEvents="none" damit der View
* GARANTIERT die Touches abbekommt.
* - useNativeDriver ist bewusst AUS — sonst koennen wir setValue()
* nicht synchron mit dem Pan-Responder zusammen nutzen.
*/
import React, { useMemo, useRef } from 'react';
import {
Animated,
PanResponder,
GestureResponderEvent,
ImageStyle,
StyleProp,
StyleSheet,
View,
} from 'react-native';
interface Props {
uri: string;
containerWidth: number;
containerHeight: number;
style?: StyleProp<ImageStyle>;
}
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_DIST = 30; // Bewegung max. damit ein Tap als Tap gilt
const PAN_SLOP_AT_SCALE_1 = 4; // Mikro-Movement nicht als Pan werten
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
// Animated-Werte fuer die Render-Transformation
const scale = useRef(new Animated.Value(1)).current;
const tx = useRef(new Animated.Value(0)).current;
const ty = useRef(new Animated.Value(0)).current;
// Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async)
const view = useRef({ scale: 1, x: 0, y: 0 }).current;
// Geste-Snapshot: was war zu Beginn dieser Geste-Phase
const gesture = useRef({
fingers: 0, // aktuelle Finger-Anzahl
startScale: 1,
startX: 0,
startY: 0,
startDist: 0, // Pinch-Referenz-Distanz
startFocalX: 0,
startFocalY: 0,
movedSinceTouch: 0, // fuer Tap-Erkennung
touchStartedAt: 0,
touchStartX: 0,
touchStartY: 0,
}).current;
// Doppel-Tap
const lastTap = useRef({ at: 0, x: 0, y: 0 });
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const applyClamped = (s: number, x: number, y: number) => {
const ns = clamp(s, MIN_SCALE, MAX_SCALE);
// Translation auf das verfuegbare Volumen begrenzen
const maxX = Math.max(0, (containerWidth * ns - containerWidth) / 2);
const maxY = Math.max(0, (containerHeight * ns - containerHeight) / 2);
const nx = clamp(x, -maxX, maxX);
const ny = clamp(y, -maxY, maxY);
view.scale = ns;
view.x = nx;
view.y = ny;
scale.setValue(ns);
tx.setValue(nx);
ty.setValue(ny);
};
const distance = (touches: any[]) =>
Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
const focal = (touches: any[]) => ({
x: (touches[0].pageX + touches[1].pageX) / 2,
y: (touches[0].pageY + touches[1].pageY) / 2,
});
// Snapshot vor jedem Phasenwechsel (1↔2 Finger) — verhindert Spruenge
const snapshot = (touches: any[]) => {
gesture.startScale = view.scale;
gesture.startX = view.x;
gesture.startY = view.y;
if (touches.length >= 2) {
gesture.startDist = distance(touches);
const f = focal(touches);
gesture.startFocalX = f.x;
gesture.startFocalY = f.y;
} else if (touches.length === 1) {
gesture.startDist = 0;
gesture.startFocalX = touches[0].pageX;
gesture.startFocalY = touches[0].pageY;
}
};
const responder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: (e: GestureResponderEvent) => {
const touches = e.nativeEvent.touches as any[];
gesture.fingers = touches.length;
gesture.movedSinceTouch = 0;
gesture.touchStartedAt = Date.now();
gesture.touchStartX = touches[0]?.pageX ?? 0;
gesture.touchStartY = touches[0]?.pageY ?? 0;
snapshot(touches);
},
onPanResponderMove: (e: GestureResponderEvent, _gs) => {
const touches = e.nativeEvent.touches as any[];
// Phasenwechsel? → Re-Snapshot, damit nicht gesprungen wird
if (touches.length !== gesture.fingers) {
gesture.fingers = touches.length;
snapshot(touches);
return;
}
gesture.movedSinceTouch += 1;
if (touches.length >= 2) {
// Pinch + Pan via Focal
const d = distance(touches);
if (gesture.startDist === 0) {
// Sicherheitsnetz falls Snapshot gemissed wurde
snapshot(touches);
return;
}
const factor = d / gesture.startDist;
const f = focal(touches);
const newScale = clamp(gesture.startScale * factor, MIN_SCALE, MAX_SCALE);
// Focal-basierter Pan: zoomt um den Mittelpunkt der zwei Finger
const newX = gesture.startX + (f.x - gesture.startFocalX);
const newY = gesture.startY + (f.y - gesture.startFocalY);
applyClamped(newScale, newX, newY);
} else if (touches.length === 1 && view.scale > 1.02) {
const dx = touches[0].pageX - gesture.startFocalX;
const dy = touches[0].pageY - gesture.startFocalY;
if (Math.abs(dx) < PAN_SLOP_AT_SCALE_1 && Math.abs(dy) < PAN_SLOP_AT_SCALE_1) return;
applyClamped(view.scale, gesture.startX + dx, gesture.startY + dy);
}
},
onPanResponderRelease: (e: GestureResponderEvent) => {
const elapsed = Date.now() - gesture.touchStartedAt;
const dx = (e.nativeEvent.changedTouches?.[0]?.pageX ?? gesture.touchStartX) - gesture.touchStartX;
const dy = (e.nativeEvent.changedTouches?.[0]?.pageY ?? gesture.touchStartY) - gesture.touchStartY;
const wasTap =
elapsed < 280 &&
Math.abs(dx) < DOUBLE_TAP_DIST &&
Math.abs(dy) < DOUBLE_TAP_DIST;
if (wasTap) {
const now = Date.now();
if (now - lastTap.current.at < DOUBLE_TAP_MS) {
// Doppel-Tap → Zoom-Toggle
if (view.scale > 1.1) {
applyClamped(1, 0, 0);
} else {
applyClamped(2.5, 0, 0);
}
lastTap.current = { at: 0, x: 0, y: 0 };
} else {
lastTap.current = { at: now, x: gesture.touchStartX, y: gesture.touchStartY };
}
}
gesture.fingers = 0;
gesture.startDist = 0;
},
onPanResponderTerminate: () => {
gesture.fingers = 0;
gesture.startDist = 0;
},
}),
[],
);
return (
<View
style={StyleSheet.absoluteFill}
collapsable={false}
{...responder.panHandlers}
>
<Animated.View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [{ translateX: tx }, { translateY: ty }, { scale }],
},
]}
resizeMode="contain"
/>
</Animated.View>
</View>
);
};
export default ZoomableImage;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* ariaView — Empfaenger der von ARIA komponierten RAEUMLICHEN Ansichten (M1).
*
* Fluss: ARIA ruft im Brain `present_view` → Brain-Event `aria_view` → Bridge →
* RVS `aria_view` → hier gepuffert → WorkspaceCanvas rendert Orb + Karten, die
* auf der Flaeche materialisieren.
*
* Der Service haelt pro Projekt die AKTUELLE View-Spec, damit eine spaet
* gemountete Canvas-Kachel sofort den Ist-Stand bekommt. Muster wie
* services/codeFile.ts (Singleton, rvs.onMessage).
*
* Die Karten-Typen sind bewusst offen (string), damit spaetere Renderer (vnc,
* chart, file …) ohne Service-Aenderung dazukommen. Der jeweilige Client-Renderer
* entscheidet, was er mit einem unbekannten Typ macht (i.d.R. ignorieren).
*/
import rvs, { RVSMessage } from './rvs';
export type OrbState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'working';
export interface ViewMarker {
lat: number;
lon: number;
label?: string;
}
export interface ViewCard {
type: 'text' | 'image' | 'map' | 'code' | 'list' | string;
title?: string;
md?: string; // text/list
src?: string; // image
markers?: ViewMarker[]; // map
path?: string; // code
lang?: string; // code
// Zukuenftige Kartenfelder ohne Service-Aenderung:
[k: string]: any;
}
export interface ViewSpec {
cards: ViewCard[];
orb?: OrbState;
title?: string;
}
export interface AriaView {
projectId: string;
view: ViewSpec;
clientMsgId?: string;
ts: number;
}
type ViewSub = (v: AriaView) => void;
class AriaViewService {
private views = new Map<string, AriaView>();
private subs: ViewSub[] = [];
constructor() {
rvs.onMessage((m) => this.onMessage(m));
}
private onMessage(m: RVSMessage): void {
if (m.type !== 'aria_view') return;
const p = (m.payload || {}) as any;
const raw = (p.view || {}) as any;
const cards: ViewCard[] = Array.isArray(raw.cards) ? raw.cards : [];
if (cards.length === 0) return; // leere Ansicht ignorieren
const view: ViewSpec = {
cards,
orb: raw.orb || 'speaking',
title: raw.title || '',
};
const projectId: string = p.projectId || '';
const entry: AriaView = {
projectId,
view,
clientMsgId: p.clientMsgId || '',
ts: Date.now(),
};
this.views.set(projectId, entry);
this.subs.forEach((cb) => {
try { cb(entry); } catch {}
});
}
/** Aktuelle Ansicht eines Projekts (leer = Hauptchat). */
getView(projectId: string): AriaView | undefined {
return this.views.get(projectId || '');
}
/** Registriert einen Listener fuer neue Ansichten. */
subscribe(cb: ViewSub): () => void {
this.subs.push(cb);
return () => { this.subs = this.subs.filter((s) => s !== cb); };
}
/** Ansicht eines Projekts verwerfen (z.B. wenn der User sie wegwischt). */
clear(projectId: string): void {
this.views.delete(projectId || '');
}
}
const ariaView = new AriaViewService();
export default ariaView;
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
/**
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
* persistente Notification zeigt solange irgendein Slot aktiv ist.
*
* Mehrere Komponenten koennen den Service unabhaengig "halten":
* - 'tts' : ARIA spricht
* - 'rec' : Aufnahme laeuft
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
* - 'location' : Background-GPS-Tracking (opt-in in Settings)
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
* → Trigger-Replies, Reconnects, Push-Reaktionen.
*
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
* den hoechstprioren Slot an (tts > rec > wake > location > background).
*/
import { NativeModules } from 'react-native';
interface BackgroundAudioNative {
start(reason: string): Promise<boolean>;
stop(): Promise<boolean>;
}
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
// ist die fallback-Anzeige wenn nichts anderes laeuft.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
function topReason(): string {
for (const s of PRIORITY) {
if (slots.has(s)) return s;
}
return '';
}
async function applyState(): Promise<void> {
if (!BackgroundAudio) return;
if (slots.size === 0) {
try { await BackgroundAudio.stop(); } catch {}
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
import('./logger').then(m => m.reportAppDebug('bg.stop', 'service stopped')).catch(()=>{});
return;
}
const reason = topReason();
try {
await BackgroundAudio.start(reason);
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
reason, [...slots].join('+'));
import('./logger').then(m => m.reportAppDebug('bg.start', `slot=${reason} all=[${[...slots].join(',')}]`)).catch(()=>{});
} catch (err: any) {
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
import('./logger').then(m => m.reportAppDebug('bg.start.fail', err?.message || String(err))).catch(()=>{});
}
}
export async function acquireBackgroundAudio(slot: Slot): Promise<void> {
if (slots.has(slot)) return;
slots.add(slot);
await applyState();
}
export async function releaseBackgroundAudio(slot: Slot): Promise<void> {
if (!slots.has(slot)) return;
slots.delete(slot);
await applyState();
}
export function backgroundAudioActive(): boolean {
return slots.size > 0;
}
// --- Legacy API (nur tts-Slot) — fuer Aufruf-Sites die noch nichts vom Slot-
// system wissen. Mappt auf den 'tts'-Slot. ---
export const startBackgroundAudio = () => acquireBackgroundAudio('tts');
export const stopBackgroundAudio = () => releaseBackgroundAudio('tts');
+687
View File
@@ -0,0 +1,687 @@
/**
* Brain-API-Client fuer die App.
*
* Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir
* tunneln alle Memory-Operationen ueber den generischen brain_request /
* brain_response RVS-Channel den die Bridge implementiert.
*
* Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende
* brain_response, Promise loest auf / wird abgelehnt bei status>=400.
*/
import rvs from './rvs';
type AnyJson = any;
interface PendingRequest {
resolve: (data: AnyJson) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
expectBinary?: boolean;
}
const pending = new Map<string, PendingRequest>();
let installed = false;
function _ensureListener() {
if (installed) return;
installed = true;
rvs.onMessage((msg: any) => {
if (!msg || msg.type !== 'brain_response') return;
const p = msg.payload || {};
const reqId: string = p.requestId || '';
const handler = pending.get(reqId);
if (!handler) return;
pending.delete(reqId);
clearTimeout(handler.timer);
const status: number = Number(p.status || 0);
if (status >= 200 && status < 300) {
if (handler.expectBinary) {
handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' });
} else {
handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null));
}
} else {
const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`;
handler.reject(new Error(`Brain ${status}: ${detail}`));
}
});
}
let _nextId = 0;
function _newRequestId(): string {
_nextId += 1;
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;
bodyBase64?: string;
contentType?: string;
expectBinary?: boolean;
timeoutMs?: number;
}
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
_ensureListener();
// Fast-Fail wenn RVS nicht verbunden — sonst tickt der Timeout 30s und
// der TriggerBrowser / Dateimanager zeigt ne ewig drehende Spinner.
// Stefan-Bug 06/2026: "Connection refused, App haengt 30 Sekunden".
const rvsState = rvs.getState();
if (rvsState !== 'connected') {
return Promise.reject(new Error(
`Keine Verbindung zum Brain (RVS: ${rvsState}). Warte auf Reconnect...`,
));
}
return new Promise((resolve, reject) => {
const requestId = _newRequestId();
const timer = setTimeout(() => {
if (pending.delete(requestId)) {
reject(new Error(`Brain-Timeout fuer ${path}`));
}
}, opts.timeoutMs || 30000);
pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary });
rvs.send('brain_request' as any, {
requestId,
method: opts.method || 'GET',
path,
...(opts.body !== undefined ? { body: opts.body } : {}),
...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}),
...(opts.contentType ? { contentType: opts.contentType } : {}),
});
});
}
// ── Typen ────────────────────────────────────────────────────────────
export interface MemoryAttachment {
name: string;
mime: string;
size: number;
path: string;
}
export interface Memory {
id: string;
type: string;
title: string;
content: string;
pinned: boolean;
category: string;
source: string;
tags: string[];
created_at: string;
updated_at: string;
conversation_id?: string | null;
score?: number | null;
attachments?: MemoryAttachment[];
}
/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */
export interface OAuthServiceStatus {
service: string;
configured: boolean;
authenticated: boolean;
expiresAt?: number | null;
expiresInSec?: number | null;
hasRefresh: boolean;
scope?: string;
isDefault: boolean;
}
/** OAuth-App-Config (client_id/scopes/URLs) — client_secret kommt NIE rausgegeben. */
export interface OAuthAppConfig {
client_id: string;
has_client_secret: boolean;
scopes?: string[] | null;
auth_url?: string | null;
token_url?: string | null;
}
/** Projekt — Stefans Threading-Konzept im Hauptchat. */
export interface Project {
id: string;
name: string;
description: string;
status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number;
updated_at: number;
last_activity_at: number;
turn_count: number;
// Workspace: 'code' blendet Editor-/VNC-Kacheln ein. ARIA setzt das selbst
// via set_project_kind; fehlt/undefined = 'chat' (nur Chat-Kachel).
kind?: 'code' | 'chat';
// Optionale absolute noVNC-URL (falls der Desktop direkt erreichbar ist,
// sonst laeuft der VNC-Stream als RFB-Bytes durch RVS).
desktop_url?: string;
// Automatisch: hat das Projekt Dateien in /shared/projects/<id>/? → Datei-Symbol.
has_files?: boolean;
file_count?: number;
}
export interface ProjectStatus {
active_id: string;
active: Project | null;
projects: Project[];
}
/** QEMU-VM eines Projekts (Registry + Live-Status). */
export interface ProjectVm {
name: string;
arch: string;
iso?: string;
vnc_display: number;
mem: number;
running?: boolean;
vnc_port?: number;
boot_cmd?: string;
created_at?: number;
}
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
* Key "__main__" = Hauptchat, sonst project_id. */
export interface QueueContextStatus {
busy: boolean;
queue_size: number;
}
export interface ProjectQueueStatus {
contexts: Record<string, QueueContextStatus>;
}
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
export interface Skill {
name: string;
description: string;
execution: string; // local-venv | local-bin | bash
entry: string; // run.py | run.sh
args?: any[]; // [{name, type, required, description}]
requires?: { pip?: string[]; binaries?: string[] };
active: boolean;
created_at?: string;
updated_at?: string;
last_used?: string | null;
use_count?: number;
version?: string;
author?: string; // "aria" | "stefan"
setup_error?: string;
// P3: konfigurierbare Werte (API-Keys, IDs etc.) — Stefan setzt sie hier,
// Skill bekommt sie als CFG_<NAME> ENV. Werte selbst kommen via /config.
config_schema?: SkillConfigField[];
// P4: Versions-Historie. Detail-Liste kommt via /versions.
version_history?: { version_id: string; archived_at?: string; summary?: string }[];
}
export interface SkillConfigField {
name: string;
type: 'string' | 'number' | 'boolean' | 'password';
label?: string;
secret?: boolean;
description?: string;
default?: any;
}
export interface SkillVersion {
version_id: string;
archived_at?: string;
summary?: string;
}
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
export interface Trigger {
name: string;
type: 'timer' | 'watcher' | string;
active: boolean;
author?: string;
message: string;
fires_at?: string; // ISO, nur timer
condition?: string; // nur watcher
check_interval_sec?: number; // nur watcher
throttle_sec?: number; // nur watcher
fire_count?: number;
last_fired_at?: string | null;
last_checked_at?: string | null;
created_at?: string;
updated_at?: string;
}
// ── Memory CRUD ──────────────────────────────────────────────────────
export const brainApi = {
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
getMemory(id: string): Promise<Memory> {
return _send(`/memory/get/${encodeURIComponent(id)}`);
},
/** Liste aller Memories, optional nach Type gefiltert. */
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
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 = _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 = _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. */
saveMemory(body: {
type: string;
title: string;
content: string;
pinned?: boolean;
category?: string;
tags?: string[];
}): Promise<Memory> {
return _send('/memory/save', {
method: 'POST',
body: { source: 'app', ...body },
});
},
/** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */
updateMemory(id: string, body: Partial<Pick<Memory, 'title' | 'content' | 'pinned' | 'category' | 'tags'>>): Promise<Memory> {
return _send(`/memory/update/${encodeURIComponent(id)}`, {
method: 'PATCH',
body,
});
},
/** Memory loeschen. */
deleteMemory(id: string): Promise<{ deleted: string }> {
return _send(`/memory/delete/${encodeURIComponent(id)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
// ── Anhaenge ────────────────────────────────────────────────────────
/** Datei als Anhang an die Memory haengen (Base64-Upload). */
uploadAttachment(memoryId: string, name: string, base64: string): Promise<Memory> {
return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, {
method: 'POST',
body: { name, data_base64: base64 },
timeoutMs: 120000,
});
},
/** Anhang loeschen. */
deleteAttachment(memoryId: string, filename: string): Promise<Memory> {
return _send(
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
{ method: 'DELETE' },
);
},
/** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */
getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> {
return _send(
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
{ expectBinary: true, timeoutMs: 60000 },
);
},
// ── Triggers ────────────────────────────────────────────────────────
/** Liste aller Trigger (aktive + inaktive).
* Brain returnt {triggers: [...]} — wir unwrappen damit der Caller einfach
* t.sort/filter/map nutzen kann. Ohne das Unwrap warf t.sort() eine
* TypeError-Exception und der TriggerBrowser blieb leer. */
listTriggers(): Promise<Trigger[]> {
return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || []));
},
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
getTrigger(name: string): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`);
},
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
return _send('/triggers/conditions');
},
/** Trigger-Logs (last N Feuerungen). */
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
},
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
return _send('/triggers/timer', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Watcher anlegen. */
createWatcher(body: {
name: string;
condition: string;
message: string;
check_interval_sec?: number;
throttle_sec?: number;
author?: string;
}): Promise<Trigger> {
return _send('/triggers/watcher', {
method: 'POST',
body: { author: 'app', ...body },
});
},
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
updateTrigger(name: string, body: Partial<{
active: boolean;
message: string;
condition: string;
throttle_sec: number;
check_interval_sec: number;
fires_at: string;
}>): Promise<Trigger> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'PATCH',
body,
});
},
/** Trigger loeschen. */
deleteTrigger(name: string): Promise<{ deleted: string }> {
return _send(`/triggers/${encodeURIComponent(name)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
// ── Skills ────────────────────────────────────────────────────────
/** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */
listSkills(): Promise<Skill[]> {
return _send('/skills/list').then((r: any) => Array.isArray(r) ? r : (r?.skills || []));
},
/** Einzelnen Skill holen (inkl. setup_error, last_used, use_count). */
getSkill(name: string): Promise<Skill> {
return _send(`/skills/${encodeURIComponent(name)}`);
},
/** Skill ausfuehren (mit args als ENV ARG_XXX). Skill-Run kann lange dauern,
* 5 min Default-Timeout. */
runSkill(name: string, args: Record<string, any> = {}): Promise<{
ok: boolean; exit_code: number; stdout: string; stderr: string;
duration_sec: number; log_path?: string;
}> {
return _send('/skills/run', {
method: 'POST',
body: { name, args, timeout_sec: 300 },
timeoutMs: 320000,
});
},
/** Skill-Manifest aendern (description, active, args...). Code-Aenderungen
* gehen ueber ARIAs eigene skill_update-Tool — die App-UI sollte sie
* NICHT direkt anbieten (zu fehleranfaellig). */
updateSkill(name: string, body: Partial<{
description: string;
active: boolean;
args: any[];
version: string;
}>): Promise<Skill> {
return _send(`/skills/${encodeURIComponent(name)}`, {
method: 'PATCH',
body,
timeoutMs: 15000,
});
},
/** Skill loeschen (samt venv + logs). */
deleteSkill(name: string): Promise<{ deleted: string }> {
return _send(`/skills/${encodeURIComponent(name)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
/** Letzte Run-Logs eines Skills. */
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`)
.then((r: any) => Array.isArray(r) ? r : (r?.logs || []));
},
/** P3: Config-Schema + aktuelle Werte (secret-Felder gemaskt mit '***SET***'). */
getSkillConfig(name: string): Promise<{ schema: SkillConfigField[]; values: Record<string, any> }> {
return _send(`/skills/${encodeURIComponent(name)}/config`)
.then((r: any) => ({ schema: r?.schema || [], values: r?.values || {} }));
},
/** P3: Config-Werte komplett ueberschreiben. Werte greifen ab dem naechsten Run. */
setSkillConfig(name: string, values: Record<string, any>): Promise<{ ok: boolean; values: Record<string, any> }> {
return _send(`/skills/${encodeURIComponent(name)}/config`, {
method: 'POST',
body: { values },
timeoutMs: 10000,
});
},
/** P4: Liste archivierter Versionen, neueste zuerst. */
listSkillVersions(name: string): Promise<SkillVersion[]> {
return _send(`/skills/${encodeURIComponent(name)}/versions`)
.then((r: any) => r?.versions || []);
},
/** P4: Rollback auf eine fruehere Version. Aktueller Stand wird automatisch gesichert. */
rollbackSkill(name: string, versionId: string): Promise<{ ok: boolean; rolled_back_to: string; safety_snapshot: string }> {
return _send(`/skills/${encodeURIComponent(name)}/rollback`, {
method: 'POST',
body: { version_id: versionId },
timeoutMs: 60000, // venv-Rebuild kann dauern
});
},
/** P4: Einzelne Version dauerhaft loeschen. */
deleteSkillVersion(name: string, versionId: string): Promise<{ ok: boolean; deleted: string }> {
return _send(`/skills/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`, {
method: 'DELETE',
timeoutMs: 10000,
});
},
// ── OAuth ────────────────────────────────────────────────────────
/** Liste aller Services mit Auth-Status (configured/authenticated/expires). */
listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> {
return _send('/oauth/services');
},
/** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */
getOAuthApps(): Promise<{ apps: Record<string, OAuthAppConfig>; defaults: string[] }> {
return _send('/oauth/apps');
},
/** Provider-Config setzen/aktualisieren. Leerer client_secret laesst
* den bestehenden Wert stehen. */
saveOAuthApp(body: {
service: string;
client_id?: string;
client_secret?: string;
scopes?: string[];
auth_url?: string;
token_url?: string;
}): Promise<{ ok: boolean; service: string }> {
return _send('/oauth/apps', {
method: 'POST',
body,
timeoutMs: 15000,
});
},
/** Service-Eintrag komplett entfernen (incl. Token). */
deleteOAuthApp(service: string): Promise<{ ok: boolean }> {
return _send(`/oauth/apps/${encodeURIComponent(service)}`, {
method: 'DELETE',
timeoutMs: 15000,
});
},
/** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */
authorizeOAuth(service: string, scopes?: string[]): Promise<{
url: string; state: string; redirect_uri: string; service: string;
}> {
return _send('/oauth/authorize', {
method: 'POST',
body: { service, scopes },
timeoutMs: 15000,
});
},
/** Token loeschen (lokal — kein Provider-Revoke). */
revokeOAuth(service: string): Promise<{ ok: boolean }> {
return _send(`/oauth/${encodeURIComponent(service)}/revoke`, {
method: 'POST',
timeoutMs: 15000,
});
},
// ── Projekte ───────────────────────────────────────────────────
/** Kompletter Status: aktives Projekt + Liste. */
getProjectStatus(): Promise<ProjectStatus> {
return _send('/projects/status');
},
/** Nur die Liste — fuer Sidebar/Drawer. */
listProjects(includeArchived: boolean = false): Promise<Project[]> {
return _send(`/projects/list${includeArchived ? '?include_archived=true' : ''}`)
.then((r: any) => r?.projects || []);
},
/** Neues Projekt anlegen — wird automatisch aktiviert. */
createProject(body: { name: string; description?: string }): Promise<Project> {
return _send('/projects/create', {
method: 'POST',
body: { description: '', ...body },
});
},
/** Aktives Projekt wechseln. Leerer projectId = Hauptthread. */
switchProject(projectId: string): Promise<ProjectStatus> {
return _send('/projects/switch', {
method: 'POST',
body: { project_id: projectId },
});
},
/** Projekt als beendet markieren (bleibt sichtbar, aktiv ist dann der Hauptthread). */
endProject(projectId: string): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}/end`, {
method: 'POST',
});
},
/** Projekt archivieren (verschwindet aus der Default-Liste). */
archiveProject(projectId: string): Promise<{ id: string; status: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/archive`, {
method: 'POST',
});
},
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { kind },
});
},
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
},
/** Inhalt einer Projekt-Datei laden (Text). */
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
},
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
},
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
},
addProjectVm(projectId: string, body: { name: string; arch?: string; iso?: string; vnc_display?: number; mem?: number; create_disk?: boolean; size?: string }): Promise<ProjectVm> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { method: 'POST', body, timeoutMs: 30000 });
},
removeProjectVm(projectId: string, name: string, purge = false): Promise<{ ok: boolean }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}?purge=${purge ? 'true' : 'false'}`, { method: 'DELETE' });
},
bootProjectVm(projectId: string, name: string): Promise<{ ok: boolean; vnc_port: number; output: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/boot`, { method: 'POST', timeoutMs: 45000 });
},
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
},
/** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */
screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 });
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { hidden },
});
},
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
return _send('/projects/queue-status');
},
};
export default brainApi;
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
/**
* desktop — Desktop-/VNC-Anbindung fuer Code-Projekte.
*
* Zwei Aufgaben:
* 1. Verfuegbarkeit: `check_desktop` triggert die Bridge, `desktop_status`
* meldet zurueck ob eine QEMU-VNC laeuft (und ggf. eine direkte URL).
* 2. VNC-Tunnel: der noVNC-Client in der App-WebView spricht kein eigenes
* WebSocket, sondern schickt RFB-Bytes als `vnc_input` (Base64) ueber RVS;
* die Bridge oeffnet die TCP-Verbindung zu QEMU (host:5901) und streamt die
* Antwort als `vnc_data` zurueck. Base64-in-JSON wie audio_pcm.
*
* Eine Session = ein Desktop; wir nutzen die Projekt-ID als Session-Key (leer =
* 'main'). Muster wie services/rvs.ts (Singleton mit Listener-Listen).
*/
import rvs, { RVSMessage } from './rvs';
export interface DesktopStatus {
available: boolean;
session: string;
/** optionale direkte noVNC-URL (falls Host direkt erreichbar) */
url?: string;
message?: string;
}
type StatusSub = (s: DesktopStatus) => void;
type VncDataSub = (b64: string) => void;
const DEFAULT_VNC_PORT = 5901;
const sessionOf = (projectId: string) => projectId || 'main';
class DesktopService {
private status: DesktopStatus = { available: false, session: '' };
private statusSubs: StatusSub[] = [];
private vncDataSubs: VncDataSub[] = [];
private currentSession = '';
constructor() {
rvs.onMessage((m) => this.onMessage(m));
}
private onMessage(m: RVSMessage): void {
const p = (m.payload || {}) as any;
if (m.type === 'desktop_status') {
this.status = {
available: !!p.available,
session: p.session || '',
url: typeof p.url === 'string' ? p.url : undefined,
message: p.message,
};
const s = this.status;
this.statusSubs.forEach((cb) => cb(s));
} else if (m.type === 'vnc_data') {
if (this.currentSession && p.session && p.session !== this.currentSession) return;
const b64 = typeof p.b64 === 'string' ? p.b64 : '';
if (b64) this.vncDataSubs.forEach((cb) => cb(b64));
}
}
getStatus(): DesktopStatus {
return this.status;
}
subscribeStatus(cb: StatusSub): () => void {
this.statusSubs.push(cb);
cb(this.status);
return () => { this.statusSubs = this.statusSubs.filter((s) => s !== cb); };
}
/** Bridge fragen, ob fuer dieses Projekt ein QEMU-Desktop laeuft. */
requestCheck(projectId: string, port: number = DEFAULT_VNC_PORT): void {
rvs.send('check_desktop', { projectId: projectId || '', session: sessionOf(projectId), port });
}
/** VNC-Tunnel oeffnen — Bridge verbindet TCP zu QEMU. */
openVnc(projectId: string, port: number = DEFAULT_VNC_PORT): string {
const session = sessionOf(projectId);
this.currentSession = session;
rvs.send('vnc_open', { session, port });
return session;
}
closeVnc(): void {
if (this.currentSession) rvs.send('vnc_close', { session: this.currentSession });
this.currentSession = '';
}
/** RFB-Bytes (Base64) aus der noVNC-WebView an die Bridge weiterreichen. */
sendInput(b64: string): void {
if (!this.currentSession) return;
rvs.send('vnc_input', { session: this.currentSession, b64 });
}
/** Listener fuer eingehende RFB-Bytes (Base64) — die noVNC-WebView. */
onVncData(cb: VncDataSub): () => void {
this.vncDataSubs.push(cb);
return () => { this.vncDataSubs = this.vncDataSubs.filter((s) => s !== cb); };
}
}
const desktop = new DesktopService();
export default desktop;
+236
View File
@@ -0,0 +1,236 @@
/**
* GPS-Tracking-Service.
*
* Wenn aktiv: pushed alle paar Sekunden die aktuelle Position als
* `location_update {lat, lon}` an den RVS-Server, damit Brain-Watcher
* mit `near()`-Conditions etwas zum Vergleichen haben.
*
* Default: AUS. Wird entweder vom User manuell in Settings angeschaltet
* oder von ARIA via location_tracking-RVS-Message (Brain-Tool
* `request_location_tracking`).
*
* Energie-Schutz: distanceFilter 30m, interval 15s. Echte Fahrt-Updates
* (Geschwindigkeit) kommen sauber durch, stationaer wird kaum gesendet.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
import Geolocation from '@react-native-community/geolocation';
import rvs from './rvs';
import { acquireBackgroundAudio, releaseBackgroundAudio } from './backgroundAudio';
// Opt-in Background-GPS — Settings-Toggle "GPS auch im Hintergrund".
// Default AUS. Wenn AN: ACCESS_BACKGROUND_LOCATION-Permission noetig
// (kann nicht ueber Standard-Dialog angefordert werden, User muss in
// Android-Settings auf "Immer erlauben" gehen) + ForegroundService mit
// foregroundServiceType=location wird hochgezogen.
export const BG_GPS_STORAGE_KEY = 'aria_gps_background_enabled';
export async function isBackgroundGpsEnabled(): Promise<boolean> {
try {
const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY);
return v === 'true';
} catch {
return false;
}
}
export async function setBackgroundGpsEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_GPS_STORAGE_KEY, String(enabled));
} catch {}
}
/** Prueft ob ACCESS_BACKGROUND_LOCATION gewaehrt ist und oeffnet sonst die
* Android-App-Settings damit der User "Immer erlauben" auswaehlen kann.
* Returns true wenn permission ok, false wenn User Settings oeffnen muss. */
export async function ensureBackgroundLocationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.check(
'android.permission.ACCESS_BACKGROUND_LOCATION' as any,
);
if (granted) return true;
// Erst FINE_LOCATION anfordern falls noch nicht da
const fine = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
if (fine !== PermissionsAndroid.RESULTS.GRANTED) return false;
// Ab Android 10+ kann BACKGROUND_LOCATION NICHT ueber den normalen
// PermissionsAndroid.request abgefragt werden — User muss in Settings
// auf "Immer erlauben" wechseln. Wir oeffnen die App-Settings-Seite.
ToastAndroid.show(
'Bitte in Android-Einstellungen unter Standort "Immer erlauben" auswaehlen',
ToastAndroid.LONG,
);
Linking.openSettings();
return false;
} catch (e) {
console.warn('[gps-track] BG-Permission-Check fehlgeschlagen:', e);
return false;
}
}
type Listener = (active: boolean) => void;
class GpsTrackingService {
private watchId: number | null = null;
private active = false;
private listeners: Set<Listener> = new Set();
// Defensive: nicht zu schnell oeffentlich togglen
private lastChangeAt = 0;
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
// mehr triggert.
private lastLat: number | null = null;
private lastLon: number | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
isActive(): boolean {
return this.active;
}
onChange(cb: Listener): () => void {
this.listeners.add(cb);
return () => { this.listeners.delete(cb); };
}
private notify() {
for (const cb of this.listeners) {
try { cb(this.active); } catch {}
}
}
/** Beim App-Start: gespeicherten Zustand wiederherstellen (Default off). */
async restoreFromStorage(): Promise<void> {
try {
const v = await AsyncStorage.getItem('aria_gps_tracking');
if (v === 'true') {
console.log('[gps-track] Restore: war an, starte wieder');
this.start('Beim Start wiederhergestellt');
}
} catch {}
}
private async ensurePermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'GPS-Tracking',
message: 'ARIA braucht laufende Standort-Updates damit GPS-Watcher (Blitzer-Warner, near()) funktionieren.',
buttonPositive: 'Erlauben',
buttonNegative: 'Abbrechen',
},
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch (e) {
console.warn('[gps-track] Permission-Fehler:', e);
return false;
}
}
async start(reason: string = ''): Promise<boolean> {
if (this.active) return true;
const ok = await this.ensurePermission();
if (!ok) {
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
return false;
}
// Background-GPS opt-in: wenn aktiv, ForegroundService mit type=location
// hochziehen. Brauche ACCESS_BACKGROUND_LOCATION (User muss in Android-
// Settings 'Immer erlauben' aktivieren). Wenn die fehlt, watchPosition
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
const bgEnabled = await isBackgroundGpsEnabled();
if (bgEnabled) {
// Ohne ACCESS_BACKGROUND_LOCATION liefert watchPosition im Hintergrund
// NICHTS (Android 10+) → der Foreground-Service allein bringt nichts, und
// genau der Fall "Ankunft waehrend der Fahrt, Screen aus" faellt durch.
// Deshalb erst die Permission sicherstellen (oeffnet ggf. die Android-
// Settings fuer "Immer erlauben"), DANN den Location-Foreground-Service
// hochziehen — der haelt den Prozess wach, sodass watchPosition + der
// 60s-Heartbeat auch unter Doze weiterlaufen.
const bgOk = await ensureBackgroundLocationPermission();
if (!bgOk) {
console.warn('[gps-track] Background-Permission fehlt — Tracking nur im Vordergrund zuverlaessig');
}
try { await acquireBackgroundAudio('location'); } catch {}
}
try {
this.watchId = Geolocation.watchPosition(
(pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
this.lastLat = lat;
this.lastLon = lon;
rvs.send('location_update' as any, { lat, lon });
},
(err) => {
console.warn('[gps-track] watchPosition error:', err?.code, err?.message);
},
{
enableHighAccuracy: true,
distanceFilter: 30, // erst senden wenn 30m gewandert
interval: 15000, // (Android) gewuenschte Frequenz
fastestInterval: 10000, // (Android) max Frequenz
} as any,
);
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = setInterval(() => {
if (this.lastLat != null && this.lastLon != null) {
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
}
}, 60_000);
this.active = true;
this.lastChangeAt = Date.now();
this.notify();
AsyncStorage.setItem('aria_gps_tracking', 'true').catch(() => {});
ToastAndroid.show(
reason ? `GPS-Tracking aktiv (${reason})` : 'GPS-Tracking aktiv',
ToastAndroid.SHORT,
);
console.log('[gps-track] gestartet', reason ? `(${reason})` : '');
return true;
} catch (e: any) {
console.warn('[gps-track] start fehlgeschlagen:', e?.message);
return false;
}
}
stop(reason: string = ''): void {
if (!this.active) return;
if (this.watchId !== null) {
try { Geolocation.clearWatch(this.watchId); } catch {}
this.watchId = null;
}
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
try { releaseBackgroundAudio('location'); } catch {}
this.active = false;
this.lastChangeAt = Date.now();
this.notify();
AsyncStorage.setItem('aria_gps_tracking', 'false').catch(() => {});
ToastAndroid.show(
reason ? `GPS-Tracking aus (${reason})` : 'GPS-Tracking aus',
ToastAndroid.SHORT,
);
console.log('[gps-track] gestoppt', reason ? `(${reason})` : '');
}
async toggle(reason: string = ''): Promise<void> {
if (this.active) this.stop(reason);
else await this.start(reason);
}
}
export default new GpsTrackingService();
+248
View File
@@ -0,0 +1,248 @@
/**
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
*
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform, DeviceEventEmitter, AppState } from 'react-native';
import rvs from './rvs';
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
// auch das sehen was die App SELBST loggt (reportAppDebug/Error).
// Bisher gingen die nur via RVS an die Bridge. Lokal sichtbar = Mama-
// tauglich Debug ohne curl.
export const APP_LOG_EVENT = 'AriaLocalAppLog';
interface LocalLogEntry {
ts: number;
level: 'info' | 'warn' | 'error';
scope: string;
message: string;
}
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
// Eigener Toggle fuer Debug-Logs die ueber RVS an die Bridge gehen
// (/shared/logs/app.log → Diagnostic /api/app-log). Damit der Default-User
// nicht stuendlich Traffic + Disk-Schreiben hat, dieser ist DEFAULT AUS.
// Stefan schaltet's nur ein wenn er ein konkretes Problem debuggen muss.
export const DEBUG_LOGS_TO_BRIDGE_KEY = 'aria_debug_logs_to_bridge';
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
let _debugLogsToBridge = false;
// ─── Crash-Kontext ohne adb ─────────────────────────────────────────
// Ein RUN_MARKER bleibt gesetzt, solange die App AKTIV laeuft; bei sauberem
// Wechsel in den Hintergrund wird er geloescht. Ist er beim naechsten Start
// noch da, ist der vorige Lauf unsauber gestorben (nativer Crash/OOM — der
// schreibt KEINEN JS-Fehler, taucht also sonst nirgends auf). Wir melden das
// dann via RVS mit dem letzten Breadcrumb (was die App zuletzt tat).
const RUN_MARKER_KEY = 'aria_run_marker';
const BREADCRUMB_KEY = 'aria_last_breadcrumb';
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
let _breadcrumbDirty = false;
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
export function noteBreadcrumb(scope: string, message: string): void {
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
_breadcrumbDirty = true;
}
function applyState(): void {
console.log = _verbose ? originalLog : noop;
}
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
export async function initLogger(): Promise<void> {
try {
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
_verbose = v !== 'false'; // default: true
} catch {}
try {
const d = await AsyncStorage.getItem(DEBUG_LOGS_TO_BRIDGE_KEY);
_debugLogsToBridge = d === 'true'; // default: false
} catch {}
applyState();
await _initCrashDetection();
}
// Native-Crash-Erkennung (ohne adb) — siehe RUN_MARKER-Kommentar oben.
async function _initCrashDetection(): Promise<void> {
try {
const marker = await AsyncStorage.getItem(RUN_MARKER_KEY);
if (marker) {
let bc: any = {};
try { bc = JSON.parse((await AsyncStorage.getItem(BREADCRUMB_KEY)) || '{}'); } catch {}
const gap = bc && bc.ts ? Math.round((Date.now() - bc.ts) / 1000) : -1;
// Verzoegert melden — RVS ist beim Boot oft noch nicht verbunden.
setTimeout(() => {
reportAppError({
scope: 'app.crash-detected',
level: 'warn',
message: `Voriger Lauf ohne sauberes Shutdown beendet (nativer Crash/OOM?). `
+ `Letzte Aktivitaet: [${(bc && bc.scope) || '?'}] ${(bc && bc.message) || '?'}`
+ (gap >= 0 ? ` (vor ~${gap}s)` : ''),
});
}, 6000);
}
await AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now()));
} catch {}
// Breadcrumb throttled persistieren (alle 5s, nur wenn geaendert).
setInterval(() => {
if (_breadcrumbDirty) {
_breadcrumbDirty = false;
AsyncStorage.setItem(BREADCRUMB_KEY, JSON.stringify(_breadcrumb)).catch(() => {});
}
}, 5000);
// Sauberer Hintergrund-Wechsel → Marker weg (kein Crash). Rueckkehr → wieder
// scharf. So melden nur echte Aktiv-Crashes, kein normales Backgrounden.
try {
AppState.addEventListener('change', (s) => {
if (s === 'background') AsyncStorage.removeItem(RUN_MARKER_KEY).catch(() => {});
else if (s === 'active') AsyncStorage.setItem(RUN_MARKER_KEY, String(Date.now())).catch(() => {});
});
} catch {}
}
export function isVerboseLogging(): boolean {
return _verbose;
}
export function setVerboseLogging(verbose: boolean): void {
_verbose = verbose;
applyState();
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
}
export function isDebugLogsToBridge(): boolean {
return _debugLogsToBridge;
}
export function setDebugLogsToBridge(enabled: boolean): void {
_debugLogsToBridge = enabled;
AsyncStorage.setItem(DEBUG_LOGS_TO_BRIDGE_KEY, String(enabled)).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 {
const ts = Date.now();
noteBreadcrumb(ev.scope, ev.message);
try {
rvs.send('app_log' as any, {
ts,
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.
}
// Lokal in den App-Logs-Tab emitten — Errors gehen IMMER durch
// (unabhaengig vom Debug-Toggle).
try {
const entry: LocalLogEntry = {
ts, level: ev.level || 'error', scope: ev.scope, message: ev.message,
};
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
} catch {}
// 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 || '');
}
/** Schickt eine Debug-/Info-Message via RVS an die Bridge. Landet ebenfalls
* in /shared/logs/app.log — abrufbar via `curl /api/app-log?lines=N`.
* Im Gegensatz zu reportAppError: keine Stacktrace, level=info, kein
* console.error. Fuer Live-Diagnose im Hintergrund wenn ADB nicht da ist.
*
* Nur aktiv wenn Settings → Protokoll → Debug-Logs an Bridge AN ist.
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
* (reportAppError) gehen weiterhin IMMER durch. */
export function reportAppDebug(scope: string, message: string): void {
// Breadcrumb IMMER aktualisieren (auch wenn Debug-Logs-an-Bridge aus ist) —
// fuer den Crash-Kontext beim naechsten Boot.
noteBreadcrumb(scope, message);
if (!_debugLogsToBridge) return;
const ts = Date.now();
const trimmed = String(message).slice(0, 2000);
try {
rvs.send('app_log' as any, {
ts,
platform: Platform.OS,
level: 'info',
scope,
message: trimmed,
});
} catch {}
// Plus lokal in den App-Logs-Tab emitten — damit Stefan in der App
// selbst (Settings → Protokoll → Live Logs) sieht was passiert,
// ohne curl gegen Bridge.
try {
const entry: LocalLogEntry = { ts, level: 'info', scope, message: trimmed };
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
} catch {}
}
/** 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
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
*
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
* Status: idle / ringing / offhook
*
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
* AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
* bekommen ein "loss" Event und reagieren genauso wie auf RINGING.
*
* In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService.
* pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall.
*
* Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht
* keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert.
*/
import {
NativeEventEmitter,
NativeModules,
PermissionsAndroid,
Platform,
ToastAndroid,
} from 'react-native';
import audioService from './audio';
import wakeWordService from './wakeword';
interface PhoneCallNative {
start(): Promise<boolean>;
stop(): Promise<boolean>;
}
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
type PhoneState = 'idle' | 'ringing' | 'offhook';
class PhoneCallService {
private started: boolean = false;
private subscription: { remove: () => void } | null = null;
private focusSubscription: { remove: () => void } | null = null;
private lastState: PhoneState = 'idle';
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
* TelephonyManager-IDLE-Event kommt. */
private interruptedByFocus: boolean = false;
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
private telephonyAttached: boolean = false;
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
status(): { focusAttached: boolean; telephonyAttached: boolean } {
return {
focusAttached: this.focusSubscription !== null,
telephonyAttached: this.telephonyAttached,
};
}
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
* TelephonyManager-Listener verloren ging (kann passieren wenn der
* React-Bridge-Context recreated wurde), neu attachen. */
async refresh(): Promise<void> {
if (!this.started) return;
if (this.telephonyAttached) return; // alles ok
if (!PhoneCall) return;
try {
const ok = await PhoneCall.start();
if (ok) {
if (!this.subscription) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
}
this.telephonyAttached = true;
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
}
} catch (err: any) {
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
}
}
async start(): Promise<boolean> {
if (this.started || Platform.OS !== 'android') return false;
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
// Signal, Discord etc.) abdecken, brauchen keine Permission.
try {
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
this.focusSubscription = focusEmitter.addListener(
'AudioFocusChanged',
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
);
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
} catch (err: any) {
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
}
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
if (PhoneCall) {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
{
title: 'ARIA Cockpit — Anruf-Erkennung',
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
const ok = await PhoneCall.start();
if (ok) {
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
this.subscription = emitter.addListener(
'PhoneCallStateChanged',
(e: { state: PhoneState }) => this._onStateChanged(e.state),
);
this.telephonyAttached = true;
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
} else {
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
}
} else {
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
}
} catch (err: any) {
console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err);
}
}
this.started = true;
return true;
}
async stop(): Promise<void> {
if (!this.started) return;
try { this.subscription?.remove(); } catch {}
try { this.focusSubscription?.remove(); } catch {}
this.subscription = null;
this.focusSubscription = null;
if (PhoneCall) {
try { await PhoneCall.stop(); } catch {}
}
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
this.telephonyAttached = false;
}
private _onStateChanged(state: PhoneState): void {
if (state === this.lastState) return;
const prev = this.lastState;
console.log('[PhoneCall] State: %s → %s', prev, state);
this.lastState = state;
if (state === 'ringing' || state === 'offhook') {
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
} else if (state === 'idle' && prev !== 'idle') {
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
// Der Focus-Gain-Event triggert das Resume.
if (!this.interruptedByFocus) {
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
}
}
/** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen.
* Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen
* Audio-Playern (anderer Player startet, Notification-Sound, sogar
* unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den
* AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */
private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise<void> {
if (type === 'loss' || type === 'loss_transient') {
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
// Mode pruefen — nur echte Anrufe behandeln.
let mode = -1;
try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {}
if (mode !== 2 && mode !== 3) {
// NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt
// oder Spotify hat sich neu reingedraengelt). Keine Toasts.
console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode);
return;
}
this.interruptedByFocus = true;
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
// selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION
// = Call vorbei.
this._startVoipResumePoll();
} else if (type === 'gain') {
if (this.interruptedByFocus) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Audio frei — ARIA wieder aktiv');
}
}
}
/** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */
private voipPollTimer: ReturnType<typeof setInterval> | null = null;
private _startVoipResumePoll(): void {
if (this.voipPollTimer) return;
this.voipPollTimer = setInterval(async () => {
if (!this.interruptedByFocus) {
this._stopVoipResumePoll();
return;
}
try {
const mode = await (NativeModules.AudioFocus as any)?.getMode?.();
// 0 = MODE_NORMAL — Call ist vorbei
if (typeof mode === 'number' && mode === 0) {
this.interruptedByFocus = false;
this._stopVoipResumePoll();
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
}
} catch {}
}, 3000);
}
private _stopVoipResumePoll(): void {
if (this.voipPollTimer) {
clearInterval(this.voipPollTimer);
this.voipPollTimer = null;
}
}
private _haltForCall(toast: string): void {
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
audioService.captureInterruption();
// pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
audioService.pauseForCall(toast);
wakeWordService.pauseForCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
}
private _resumeAfterCall(toast: string): void {
// Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt
// werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat).
audioService.endCallPause();
wakeWordService.resumeFromCall().catch(() => {});
ToastAndroid.show(toast, ToastAndroid.SHORT);
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
setTimeout(() => {
audioService.resumeFromInterruption(30000).then(ok => {
if (ok) {
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
}
}).catch(() => {});
}, 800);
}
}
const phoneCallService = new PhoneCallService();
export default phoneCallService;
+105
View File
@@ -0,0 +1,105 @@
/**
* projectFocus — leichter Publish/Subscribe-Spiegel des aktuell fokussierten
* Projekt-Kontexts.
*
* ChatScreen bleibt die Quelle der Wahrheit fuer sein eigenes Rendering und
* publiziert hier bei jedem Focus-/Namens-/Kind-Wechsel EINWEG hinein. Der
* Workspace-Canvas liest/abonniert das Singleton, um zu wissen welches Projekt
* gerade aktiv ist und ob es ein Code-Projekt ist — ohne dass ChatScreen den
* Workspace kennen oder umgebaut werden muss.
*
* Muster wie services/rvs.ts (Singleton mit Listener-Liste + Unsubscribe).
*/
export type ProjectKind = 'code' | 'chat';
export interface FocusSnapshot {
/** '' = Hauptchat, sonst Projekt-ID */
focusedProjectId: string;
projectNameById: Record<string, string>;
projectKindById: Record<string, ProjectKind>;
}
type Sub = (snap: FocusSnapshot) => void;
class ProjectFocus {
private snap: FocusSnapshot = {
focusedProjectId: '',
projectNameById: {},
projectKindById: {},
};
private subs: Sub[] = [];
// --- Getter (synchron, fuer Nicht-Reaktive Leser) ---
get(): FocusSnapshot {
return this.snap;
}
getFocusedProjectId(): string {
return this.snap.focusedProjectId;
}
getProjectName(id: string): string {
return this.snap.projectNameById[id] || id;
}
/** Default 'chat' — ein Projekt ist erst 'code' wenn es explizit so
* markiert wurde (set_project_kind) oder ein Code-/Desktop-Signal kam. */
getProjectKind(id: string): ProjectKind {
return this.snap.projectKindById[id] || 'chat';
}
// --- Publisher (von ChatScreen aufgerufen) ---
setFocus(id: string): void {
if (this.snap.focusedProjectId === id) return;
this.snap = { ...this.snap, focusedProjectId: id };
this.emit();
}
setNames(map: Record<string, string>): void {
// Flacher Merge — behaelt bereits bekannte Namen, ueberschreibt neue.
this.snap = {
...this.snap,
projectNameById: { ...this.snap.projectNameById, ...map },
};
this.emit();
}
setKind(id: string, kind: ProjectKind): void {
if (this.snap.projectKindById[id] === kind) return;
this.snap = {
...this.snap,
projectKindById: { ...this.snap.projectKindById, [id]: kind },
};
this.emit();
}
setKinds(map: Record<string, ProjectKind>): void {
this.snap = {
...this.snap,
projectKindById: { ...this.snap.projectKindById, ...map },
};
this.emit();
}
// --- Abo ---
/** Registriert einen Listener und liefert sofort den aktuellen Snapshot. */
subscribe(cb: Sub): () => void {
this.subs.push(cb);
cb(this.snap);
return () => {
this.subs = this.subs.filter(s => s !== cb);
};
}
private emit(): void {
const s = this.snap;
this.subs.forEach(cb => cb(s));
}
}
const projectFocus = new ProjectFocus();
export default projectFocus;
+31 -3
View File
@@ -83,21 +83,39 @@ class RVSConnection {
// --- Verbindung ---
/** Verbindung zum RVS aufbauen */
connect(): void {
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
* schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann
* nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State
* noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und
* Stefan musste manuell zigmal klicken). */
connect(force: boolean = false): void {
if (!this.config) {
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
return;
}
if (this.ws?.readyState === WebSocket.OPEN) {
if (!force && this.ws?.readyState === WebSocket.OPEN) {
this.log('info', 'Bereits verbunden');
return;
}
// Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen
// bevor wir einen neuen aufbauen — sonst gibt's zwei parallele
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.ws = null;
}
this.shouldReconnect = true;
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.usingTLSFallback = false;
this.clearTimers();
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
this.establishConnection();
}
@@ -212,6 +230,16 @@ class RVSConnection {
this.ws = null;
this.setState('disconnected');
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
// der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht
// mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback
// auf ws:// → Caddy refused → endlos im Fallback haengen.
if (this.usingTLSFallback) {
this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)');
this.usingTLSFallback = false;
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
+75 -1
View File
@@ -29,6 +29,11 @@ class UpdateService {
private downloading = false;
constructor() {
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
// laeuft, sind frueher heruntergeladene APKs entweder schon installiert
// oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy.
this.cleanupOldApks().catch(() => {});
// Auf update_available Nachrichten lauschen
rvs.onMessage((msg: RVSMessage) => {
if (msg.type === 'update_available' as any) {
@@ -45,6 +50,71 @@ class UpdateService {
});
}
/** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
private async _apkSearchDirs(): Promise<string[]> {
const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
if ((RNFS as any).ExternalCachesDirectoryPath) {
dirs.push((RNFS as any).ExternalCachesDirectoryPath);
}
if (RNFS.ExternalDirectoryPath) {
dirs.push(RNFS.ExternalDirectoryPath);
}
return dirs;
}
/** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf.
* Public damit Settings den Button "Update-Cache leeren" benutzen kann. */
async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> {
const dirs = await this._apkSearchDirs();
let removed = 0;
let freed = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
const apks = files.filter(f => /\.apk$/i.test(f.name));
for (const f of apks) {
if (keepCurrentName && f.name === keepCurrentName) continue;
try {
const size = parseInt(f.size as any, 10) || 0;
await RNFS.unlink(f.path);
removed += 1;
freed += size;
console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`);
} catch (err: any) {
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`);
}
}
} catch (err: any) {
console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`);
}
}
const freedMB = freed / 1024 / 1024;
if (removed > 0) {
console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`);
}
return { removed, freedMB };
}
/** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */
async getApkCacheSize(): Promise<{ count: number; totalMB: number }> {
const dirs = await this._apkSearchDirs();
let count = 0;
let total = 0;
for (const dir of dirs) {
try {
if (!(await RNFS.exists(dir))) continue;
const files = await RNFS.readDir(dir);
for (const f of files) {
if (!f.isFile() || !/\.apk$/i.test(f.name)) continue;
count += 1;
total += parseInt(f.size as any, 10) || 0;
}
} catch {}
}
return { count, totalMB: total / 1024 / 1024 };
}
/** Bei App-Start Update pruefen */
checkForUpdate(): void {
if (this.checking) return;
@@ -111,11 +181,15 @@ class UpdateService {
});
});
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
// Updates in einer Session gezogen werden
await this.cleanupOldApks();
// Base64 als APK-Datei speichern
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64');
const fileSize = await RNFS.stat(destPath);
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
if (Platform.OS === 'android') {
+57
View File
@@ -0,0 +1,57 @@
/**
* viewMode — App-Ansicht: 'compact' (klassischer Vollbild-Chat wie vor 0.2.2.0)
* oder 'cockpit' (zoombarer Kachel-Desktop).
*
* Default 'compact' → fuer normale Nutzung aendert sich nichts (Mama-tauglich).
* Umschaltbar ueber den Header-Button; persistiert in AsyncStorage. Muster wie
* services/rvs.ts (Singleton mit Listener-Liste).
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
export type ViewModeValue = 'compact' | 'cockpit';
const KEY = 'aria_view_mode';
type Sub = (mode: ViewModeValue) => void;
class ViewMode {
private mode: ViewModeValue = 'compact';
private subs: Sub[] = [];
private loaded = false;
constructor() {
AsyncStorage.getItem(KEY).then((v) => {
if (v === 'cockpit' || v === 'compact') this.mode = v;
this.loaded = true;
this.emit();
}).catch(() => { this.loaded = true; });
}
get(): ViewModeValue { return this.mode; }
isLoaded(): boolean { return this.loaded; }
set(mode: ViewModeValue): void {
if (this.mode === mode) return;
this.mode = mode;
AsyncStorage.setItem(KEY, mode).catch(() => {});
this.emit();
}
toggle(): void {
this.set(this.mode === 'compact' ? 'cockpit' : 'compact');
}
subscribe(cb: Sub): () => void {
this.subs.push(cb);
cb(this.mode);
return () => { this.subs = this.subs.filter((s) => s !== cb); };
}
private emit(): void {
const m = this.mode;
this.subs.forEach((cb) => cb(m));
}
}
const viewMode = new ViewMode();
export default viewMode;
+71
View File
@@ -0,0 +1,71 @@
/**
* Spielt einen kurzen "Bereit"-Sound (Airplane Ding-Dong) wenn das Mikrofon
* nach Wake-Word-Erkennung wirklich offen ist. Datei liegt in
* android/app/src/main/res/raw/wake_ready_sound.mp3 — wird ueber Android's
* Resource-System per react-native-sound abgespielt.
*
* Toggle: AsyncStorage-Key 'aria_wake_ready_sound_enabled' (default true).
*/
import Sound from 'react-native-sound';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const WAKE_READY_SOUND_STORAGE_KEY = 'aria_wake_ready_sound_enabled';
Sound.setCategory('Playback', false);
let cachedSound: Sound | null = null;
let cachedFailed = false;
function getSound(): Promise<Sound | null> {
if (cachedFailed) return Promise.resolve(null);
if (cachedSound) return Promise.resolve(cachedSound);
return new Promise(resolve => {
const s = new Sound('wake_ready_sound', Sound.MAIN_BUNDLE, (err) => {
if (err) {
console.warn('[WakeReadySound] Konnte nicht geladen werden:', err);
cachedFailed = true;
resolve(null);
return;
}
cachedSound = s;
resolve(s);
});
});
}
/** True wenn der User den "Bereit"-Sound aktiviert hat. Default: true. */
export async function isWakeReadySoundEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(WAKE_READY_SOUND_STORAGE_KEY);
if (raw === null) return true; // Default an
return raw === 'true';
} catch {
return true;
}
}
export async function setWakeReadySoundEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_READY_SOUND_STORAGE_KEY, String(enabled));
} catch {}
}
/** Spielt den Bereit-Sound einmal ab — non-blocking. Wenn der User ihn
* in den Settings deaktiviert hat oder die Datei nicht ladbar ist,
* passiert einfach nichts. */
export async function playWakeReadySound(): Promise<void> {
if (!(await isWakeReadySoundEnabled())) return;
const s = await getSound();
if (!s) return;
try {
s.stop(() => {
s.setCurrentTime(0);
s.play((success) => {
if (!success) console.warn('[WakeReadySound] Wiedergabe fehlgeschlagen');
});
});
} catch (e) {
console.warn('[WakeReadySound] play() Exception:', e);
}
}
+674 -27
View File
@@ -1,50 +1,679 @@
/**
* Gespraechsmodus — "Ohr-Button"
* Gespraechsmodus / Wake Word Service
*
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
* Wie ein Walkie-Talkie / natuerliches Gespraech:
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
* Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
* komplett on-device via ONNX Runtime in Native-Kotlin (siehe
* OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
* Roundtrip, kein Cent Lizenzgebuehren.
*
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
* Drei Zustaende:
* off — Ohr aus, nichts laeuft
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
* AudioRecorder uebernimmt fuer die Aufnahme.
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
*
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
*/
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'listening' | 'detected';
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
export async function loadPassiveListenMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.6;
export const WAKE_THRESHOLD_MIN = 0.3;
export const WAKE_THRESHOLD_MAX = 0.9;
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
export async function loadWakeThreshold(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(WAKE_THRESHOLD_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= WAKE_THRESHOLD_MIN && n <= WAKE_THRESHOLD_MAX) return n;
}
} catch {}
return WAKE_THRESHOLD_DEFAULT;
}
export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
* werden — Diagnostic-Upload ist Phase 2. */
export const WAKE_KEYWORDS = [
'hey_jarvis',
'computer',
'alexa',
'hey_mycroft',
'hey_rhasspy',
] as const;
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
/** Hilfs-Mapping fuer die Anzeige im UI. */
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
hey_jarvis: 'Hey Jarvis',
computer: 'Computer',
alexa: 'Alexa',
hey_mycroft: 'Hey Mycroft',
hey_rhasspy: 'Hey Rhasspy',
};
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500;
interface OpenWakeWordModule {
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
start(): Promise<boolean>;
stop(): Promise<boolean>;
dispose(): Promise<boolean>;
isAvailable(): Promise<boolean>;
}
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
class WakeWordService {
private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
private bargeCallbacks: WakeWordCallback[] = [];
/** True solange Wake-Word parallel zu TTS aktiv ist. */
private bargeListening: boolean = false;
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
private callPaused: boolean = false;
private preCallState: WakeWordState = 'off';
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
* der openWakeWord faelschlich triggern kann. */
private cooldownUntilMs: number = 0;
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
* Stefan gar nicht in der App war). */
private lastTriggerAt: number = 0;
/** App liegt im Hintergrund — alle Detections sperren. Wird vom
* AppState-Listener im ChatScreen via setBackground/setForeground gesetzt.
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
private inBackground: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
* Event sofort verworfen. Reset beim Verlassen von 'conversing'.
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
* beendet den listening-State und geht zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
private passiveListenCallbacks: PassiveListenCallback[] = [];
/** Gespraechsmodus starten */
async start(): Promise<boolean> {
if (this.state === 'listening') return true;
console.log('[WakeWord] Gespraechsmodus aktiviert — Aufnahme startet nach ARIA-Antwort');
this.setState('listening');
return true;
}
/** Hook, der das Mikro freigibt (laufende Streaming-Aufnahme canceln) BEVOR
* wir OpenWakeWord.start() rufen. Ohne das haelt die passive/conversing
* Aufnahme das Mikro noch, start() schlaegt fehl → Ohr bleibt ausgegraut
* (state=off). ChatScreen registriert den Hook mit audioService.cancel…. */
private micReleaseHook: (() => Promise<void>) | null = null;
/** Gespraechsmodus stoppen */
stop(): void {
console.log('[WakeWord] Gespraechsmodus deaktiviert');
this.setState('off');
}
private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false;
private initInProgress: Promise<boolean> | null = null;
private eventSub: { remove: () => void } | null = null;
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
async resume(): Promise<void> {
if (this.state !== 'listening') return;
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'listening') {
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
this.wakeCallbacks.forEach(cb => cb());
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
async loadFromStorage(): Promise<void> {
try {
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
await this.initNative();
} catch (err) {
console.warn('[WakeWord] loadFromStorage', err);
}
}
/** ChatScreen registriert hier einen Hook, der eine laufende Streaming-
* Aufnahme cancelt (Mikro freigeben) — wird vor jedem Re-Arm gerufen. */
setMicReleaseHook(fn: (() => Promise<void>) | null): void {
this.micReleaseHook = fn;
}
private async _freeMic(): Promise<void> {
if (!this.micReleaseHook) return;
try { await this.micReleaseHook(); } catch (e) {
console.warn('[WakeWord] micReleaseHook err:', e);
}
}
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
async configure(keyword: string): Promise<boolean> {
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
? (keyword as WakeKeyword)
: DEFAULT_KEYWORD;
this.keyword = next;
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
// Laufende Instanz stoppen + neu initialisieren
await this.disposeNative();
const ok = await this.initNative();
if (!ok) {
ToastAndroid.show(
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
ToastAndroid.LONG,
);
}
return ok;
}
private async initNative(): Promise<boolean> {
if (!OpenWakeWord) {
console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
this.nativeReady = false;
return false;
}
if (this.initInProgress) return this.initInProgress;
this.initInProgress = (async () => {
try {
const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => {
console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err =>
console.warn('[WakeWord] onWakeDetected crashed:', err));
});
}
this.nativeReady = true;
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
return true;
} catch (err: any) {
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
this.nativeReady = false;
return false;
} finally {
this.initInProgress = null;
}
})();
return this.initInProgress;
}
private async disposeNative(): Promise<void> {
if (!OpenWakeWord) return;
try { await OpenWakeWord.dispose(); } catch {}
this.nativeReady = false;
}
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
async start(): Promise<boolean> {
if (this.state !== 'off') return true;
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
await acquireBackgroundAudio('wake');
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
// Debug-Log via RVS damit wir auch ohne ADB sehen wann es greift
import('./logger').then(m => m.reportAppDebug('wake.start', `armed, keyword=${this.keyword}`)).catch(()=>{});
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return true;
} catch (err: any) {
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
err?.message || err);
ToastAndroid.show(
`Wake-Word-Start failed: ${err?.message || err}`,
ToastAndroid.LONG,
);
}
} else {
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
ToastAndroid.show(
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
ToastAndroid.LONG,
);
}
// Fallback: direkt in Konversation
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
return true;
}
/** Komplett ausschalten (Ohr abschalten) */
async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert');
this.cancelPassiveListenTimer();
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
this.setState('off');
}
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
* die openWakeWord faelschlich als Trigger interpretiert. */
setResumeCooldown(ms: number = 1500): void {
this.cooldownUntilMs = Date.now() + ms;
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
}
/** App in den Hintergrund: alle Wake-Word-Detections sperren.
* Im Hintergrund will Stefan praktisch nie einen neuen Dialog starten —
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
setBackground(): void {
this.inBackground = true;
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
}
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
setForeground(): void {
this.inBackground = false;
this.cooldownUntilMs = Date.now() + 3000;
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
}
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
if (this.inBackground) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
return;
}
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
// Ausnahme: Barge-In waehrend ARIA-TTS ist ein legitimer neuer Trigger.
if (this.detectionInProgress && !this.bargeListening) {
console.log('[WakeWord] Trigger ignoriert (Detection-Zyklus laeuft schon — Native-Doppel-Event-Race)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: detectionInProgress')).catch(()=>{});
return;
}
const now = Date.now();
if (now < this.cooldownUntilMs) {
const left = this.cooldownUntilMs - now;
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
return;
}
this.detectionInProgress = true;
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
this.keyword, this.state, this.bargeListening);
import('./logger').then(m => m.reportAppDebug('wake.detect',
`keyword=${this.keyword} state=${this.state} barge=${this.bargeListening}`)).catch(()=>{});
this.lastTriggerAt = now;
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.stop();
import('./logger').then(m => m.reportAppDebug('wake.detect', 'native stop ok')).catch(()=>{});
} catch (e: any) {
import('./logger').then(m => m.reportAppDebug('wake.detect', `native stop FAIL ${e?.message}`)).catch(()=>{});
}
}
this.bargeListening = false;
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
if (this.state === 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.detect',
`barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
this.bargeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
});
// Kein erneutes setState — wir bleiben in 'conversing'.
return;
}
this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
// nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
// OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
// 200ms-Sicherheitsabstand noetig.
import('./logger').then(m => m.reportAppDebug('wake.detect',
`state→conversing, firing ${this.wakeCallbacks.length} callback(s) directly`)).catch(()=>{});
this.wakeCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] wake cb err:', e); }
});
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
async startBargeListening(): Promise<void> {
if (!this.nativeReady || !OpenWakeWord) return;
if (this.state !== 'conversing') return;
if (this.bargeListening) return;
try {
await OpenWakeWord.start();
this.bargeListening = true;
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
} catch (err) {
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
}
}
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
* naechste Aufnahme das Mikro braucht. */
async stopBargeListening(): Promise<void> {
if (!this.bargeListening) return;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Barge-Listening aus');
}
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
* soll nicht in laufende Telefonate reinhoeren. */
async pauseForCall(): Promise<void> {
if (this.callPaused) return;
this.preCallState = this.state;
if (this.state === 'off') {
this.callPaused = true; // merken dass wir pausiert wurden
return;
}
this.callPaused = true;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.stop(); } catch {}
}
this.bargeListening = false;
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
}
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
async resumeFromCall(): Promise<void> {
if (!this.callPaused) return;
const restoreTo = this.preCallState;
this.callPaused = false;
this.preCallState = 'off';
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
if (restoreTo === 'off') return;
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
// sicher zu armed degraden.
if (restoreTo === 'conversing') this.setState('armed');
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (err) {
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
}
}
}
/** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'.
*
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* ist, und unseren frisch re-armierten Listener killen.
*/
/** @param skipPassive true = KEIN passives Lauschen, direkt zurueck aufs
* Wake-Word (armed). Fuer klare Steuerbefehle (Fast-Path) — nach
* "nächster Titel" will Stefan kein 30s-Fenster, sondern Stop. */
async endConversation(skipPassive: boolean = false): Promise<void> {
if (this.state !== 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
return;
}
const wasBarge = this.bargeListening;
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
// den Listener stoppen.
this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
// Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs();
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs);
return;
}
if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
// als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try {
await this._freeMic(); // Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err: any) {
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
)).catch(()=>{});
}
}
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
import('./logger').then(m => m.reportAppDebug('wake.end',
`fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
this.setState('off');
}
/** Eintritt in den Passive-Listen-Modus: state='listening', Timer fuer
* Auto-Ende setzen, Callbacks feuern damit ChatScreen die passive
* Streaming-Aufnahme startet. OpenWakeWord bleibt AUS (Mic-Exklusivitaet —
* audioService braucht das Mikro fuer die passive Aufnahme).
* Speaker-ID-Gating (Phase 3) filtert fremde Stimmen auf der Bridge. */
private enterPassiveListening(durationMs: number): void {
this.cancelPassiveListenTimer();
this.setState('listening');
const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {});
}, durationMs);
this.passiveListenCallbacks.forEach(cb => {
try { cb(); } catch (e) { console.warn('[WakeWord] passive cb err:', e); }
});
}
/** Verlassen des Passive-Listen-Modus.
* reason='speech' → User hat was gesagt (STT-Endpoint mit text) → uebergang
* in 'conversing' (Brain antwortet, TTS spielt, dann resume → endConversation
* → wieder passive listening, repeat).
* reason='timeout' → 30s nichts gehoert → zurueck zu armed (Wake-Word wieder an).
* reason='manual' → User hat App geschlossen / stopped → zurueck zu armed. */
async exitPassiveListening(reason: 'timeout' | 'speech' | 'manual'): Promise<void> {
if (this.state !== 'listening') return;
this.cancelPassiveListenTimer();
console.log('[WakeWord] Passive-Listen Ende (reason=%s)', reason);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`exit reason=${reason}`)).catch(()=>{});
if (reason === 'speech') {
// Wechsel zu 'conversing' damit das Standard-Conversation-Flow greift
// (Brain-Response, TTS, resume etc.). Wake-Word bleibt aus (Mic belegt).
this.setState('conversing');
return;
}
// timeout oder manual → Wake-Word reaktivieren, armed-State.
if (this.nativeReady && OpenWakeWord) {
try {
await this._freeMic(); // passive Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
console.log('[WakeWord] zurueck zu armed nach passive-listen');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
console.warn('[WakeWord] re-arm nach passive-listen failed:', err);
}
}
this.setState('off');
}
private cancelPassiveListenTimer(): void {
if (this.passiveListenTimer) {
clearTimeout(this.passiveListenTimer);
this.passiveListenTimer = null;
}
}
/** Subscribe auf Passive-Listen-Events: feuert wenn der Service in den
* passiven Modus eintritt. ChatScreen startet hier eine streaming-
* Aufnahme OHNE User-Bubble (passiv lauschen). */
onPassiveListen(callback: PassiveListenCallback): () => void {
this.passiveListenCallbacks.push(callback);
return () => {
this.passiveListenCallbacks = this.passiveListenCallbacks.filter(c => c !== callback);
};
}
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
* Returnt true wenn verworfen wurde. */
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
if (this.state !== 'conversing') return false;
if (this.lastTriggerAt === 0) return false;
const age = Date.now() - this.lastTriggerAt;
if (age > maxAgeMs) return false;
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
this.lastTriggerAt = 0;
if (this.nativeReady && OpenWakeWord) {
try {
await this._freeMic(); // ggf. laufende Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
this.setState('armed');
return true;
} catch (err) {
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
}
}
this.setState('off');
return true;
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten.
*
* WICHTIG: setTimeout(800ms) kann im Hintergrund (Display aus) verspaetet
* feuern — JS-Thread ist geparkt. Wenn der Timer >2s ueberfaellig ist,
* hat der User offensichtlich die App verlassen und kommt erst spaeter
* wieder — wir oeffnen das Mikro dann NICHT, sondern beenden die
* Konversation. Sonst sieht der User nach dem App-Resume "Mikro plus-
* aufnahme laeuft" obwohl er gar nichts gesagt hat → wirkt wie Phantom-
* Wake-Word. Klassische Doze-Throttling-Falle wie bei wake.detect frueher. */
async resume(): Promise<void> {
if (this.state !== 'conversing') return;
const scheduledAt = Date.now();
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state !== 'conversing') return;
const delay = Date.now() - scheduledAt;
if (delay > 2800) {
// Timer war stark verspaetet — JS-Thread war im Hintergrund geparkt.
// Conversation als beendet behandeln statt das Mikro zu oeffnen.
console.log('[WakeWord] resume(): %dms statt ~800ms — App war im Background. endConversation statt mic-open', delay);
import('./logger').then(m => m.reportAppDebug('wake.resume',
`delayed ${delay}ms (>2800) — endConversation statt mic-open`)).catch(()=>{});
// Asynchroner Aufruf — endConversation ist async, kein await damit wir
// hier nicht in einem Promise-Chain haengen.
this.endConversation().catch(() => {});
return;
}
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window (delay=%dms)', delay);
this.wakeCallbacks.forEach(cb => cb());
}
/** True solange das Ohr aktiv ist (armed ODER conversing). */
isActive(): boolean {
return this.state === 'listening';
return this.state !== 'off';
}
isConversing(): boolean {
return this.state === 'conversing';
}
hasWakeWord(): boolean {
return this.nativeReady;
}
getKeyword(): WakeKeyword {
return this.keyword;
}
// --- Callbacks ---
@@ -56,6 +685,19 @@ class WakeWordService {
};
}
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
onBargeIn(callback: WakeWordCallback): () => void {
this.bargeCallbacks.push(callback);
return () => {
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
};
}
isBargeListening(): boolean {
return this.bargeListening;
}
onStateChange(callback: StateCallback): () => void {
this.stateCallbacks.push(callback);
return () => {
@@ -69,7 +711,12 @@ class WakeWordService {
private setState(state: WakeWordState): void {
if (this.state !== state) {
const wasConversing = this.state === 'conversing';
this.state = state;
// Re-Entry-Guard freigeben sobald wir 'conversing' verlassen — Zyklus ist durch
if (wasConversing && state !== 'conversing') {
this.detectionInProgress = false;
}
this.stateCallbacks.forEach(cb => cb(state));
}
}
+175
View File
@@ -0,0 +1,175 @@
/**
* AriaViewCanvas — die pannbare Flaeche, auf der ARIAs komponierte Ansicht
* (aria_view) MATERIALISIERT: Orb oben, darunter die Karten. Erscheint als
* Overlay ueber dem Chat, sobald ARIA present_view aufruft ("sag was → Orb denkt
* → Karte fliegt rein"). Der erste, greifbare Vorgeschmack aufs generative
* Cockpit (M1).
*
* Bedienung (NoMachine-Prinzip): 2-Finger halten + schieben bewegt die Welt,
* Pinch zoomt. Ein-Finger-Touch geht an die Karten durch (Scrollen). Die Welt
* traegt gerenderte/gestreamte Inhalte — interaktive native Panels rasten
* spaeter bei Scale 1 ein (Chat bleibt separat darunter).
*
* Geraete-agnostisch gehalten: liest nur die ViewSpec, damit ein spaeterer Web-/
* AR-Renderer dieselbe Spec konsumieren kann.
*/
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
FadeInDown,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { ViewSpec } from '../services/ariaView';
import Orb from './Orb';
import CardView from './CardView';
const MIN_SCALE = 0.5;
const MAX_SCALE = 3;
interface Props {
view: ViewSpec;
onClose: () => void;
}
const AriaViewCanvas: React.FC<Props> = ({ view, onClose }) => {
const tx = useSharedValue(0);
const ty = useSharedValue(0);
const scale = useSharedValue(1);
const savedTx = useSharedValue(0);
const savedTy = useSharedValue(0);
const savedScale = useSharedValue(1);
const pan = Gesture.Pan()
.minPointers(2)
.maxPointers(2)
.onUpdate((e) => {
tx.value = savedTx.value + e.translationX;
ty.value = savedTy.value + e.translationY;
})
.onEnd(() => {
savedTx.value = tx.value;
savedTy.value = ty.value;
});
const pinch = Gesture.Pinch()
.onUpdate((e) => {
const next = savedScale.value * e.scale;
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next));
})
.onEnd(() => {
savedScale.value = scale.value;
});
const composed = Gesture.Simultaneous(pan, pinch);
const worldStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: tx.value },
{ translateY: ty.value },
{ scale: scale.value },
],
}));
const resetCamera = () => {
tx.value = withTiming(0);
ty.value = withTiming(0);
scale.value = withTiming(1);
savedTx.value = 0;
savedTy.value = 0;
savedScale.value = 1;
};
const cards = Array.isArray(view.cards) ? view.cards : [];
return (
<View style={styles.overlay}>
<GestureDetector gesture={composed}>
<Animated.View style={[styles.world, worldStyle]}>
<View style={styles.orbWrap}>
<Orb state={view.orb} size={110} />
</View>
{!!view.title && <Text style={styles.worldTitle}>{view.title}</Text>}
<View style={styles.cards}>
{cards.map((c, i) => (
<Animated.View
key={i}
entering={FadeInDown.duration(420).delay(120 + i * 90)}
>
<CardView card={c} />
</Animated.View>
))}
</View>
</Animated.View>
</GestureDetector>
{/* Steuerung — ausserhalb des Transforms, immer bei Scale 1 bedienbar */}
<View style={styles.topBar} pointerEvents="box-none">
<TouchableOpacity style={styles.iconBtn} onPress={resetCamera}>
<Text style={styles.icon}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.iconBtn} onPress={onClose}>
<Text style={styles.icon}></Text>
</TouchableOpacity>
</View>
<View style={styles.hintWrap} pointerEvents="none">
<Text style={styles.hint}>2 Finger: schieben · Pinch: zoomen</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(6,6,16,0.94)',
zIndex: 50,
},
world: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
paddingTop: 48,
paddingHorizontal: 18,
},
orbWrap: { marginTop: 8, marginBottom: 6 },
worldTitle: {
color: '#C9C9FF',
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
textAlign: 'center',
},
cards: { width: '100%', maxWidth: 560 },
topBar: {
position: 'absolute',
top: 10,
right: 12,
flexDirection: 'row',
},
iconBtn: {
width: 40,
height: 40,
borderRadius: 20,
marginLeft: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(30,30,60,0.9)',
borderWidth: 1,
borderColor: 'rgba(123,92,255,0.4)',
},
icon: { color: '#C9C9FF', fontSize: 18 },
hintWrap: {
position: 'absolute',
bottom: 14,
alignSelf: 'center',
},
hint: {
color: '#6A6A90',
fontSize: 12,
},
});
export default AriaViewCanvas;
+114
View File
@@ -0,0 +1,114 @@
/**
* CardView — rendert EINE Karte einer aria_view-Spec (M1). Schaltet nach
* card.type auf den passenden Renderer. Unbekannte Typen werden als Text-
* Fallback gezeigt (nie crashen).
*
* Bewusst dependency-leicht (v1): Markdown wird als Klartext dargestellt, Map
* als Marker-Liste (kein Karten-Lib), Code als Monospace-Block. Spaeter koennen
* einzelne Renderer aufgebohrt werden, ohne die Spec/den Fluss zu aendern.
*/
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ViewCard, ViewMarker } from '../services/ariaView';
const ImageBody: React.FC<{ src?: string }> = ({ src }) => {
const isUrl = !!src && /^https?:\/\//i.test(src);
if (isUrl) {
return <Image source={{ uri: src }} style={styles.image} resizeMode="contain" />;
}
return <Text style={styles.muted}>🖼 {src || '(kein Bild)'}</Text>;
};
const ListBody: React.FC<{ md?: string }> = ({ md }) => {
const lines = (md || '')
.split('\n')
.map((l) => l.replace(/^\s*[-*•]\s?/, '').trim())
.filter(Boolean);
if (lines.length === 0) return <Text style={styles.muted}>(leer)</Text>;
return (
<View>
{lines.map((l, i) => (
<View key={i} style={styles.listRow}>
<Text style={styles.bullet}></Text>
<Text style={styles.text}>{l}</Text>
</View>
))}
</View>
);
};
const MapBody: React.FC<{ markers?: ViewMarker[] }> = ({ markers }) => {
const ms = Array.isArray(markers) ? markers : [];
return (
<View style={styles.map}>
<Text style={styles.mapHint}>🗺 Karte ({ms.length} Orte)</Text>
{ms.map((m, i) => (
<Text key={i} style={styles.text}>
📍 {m.label || `${m.lat?.toFixed?.(4)}, ${m.lon?.toFixed?.(4)}`}
</Text>
))}
</View>
);
};
const CodeBody: React.FC<{ md?: string; path?: string; lang?: string }> = ({ md, path, lang }) => (
<View>
{(path || lang) && (
<Text style={styles.codeCaption}>
{path || ''}{lang ? ` · ${lang}` : ''}
</Text>
)}
<ScrollView horizontal style={styles.codeScroll}>
<Text style={styles.code}>{md || ''}</Text>
</ScrollView>
</View>
);
const CardView: React.FC<{ card: ViewCard }> = ({ card }) => {
return (
<View style={styles.card}>
{!!card.title && <Text style={styles.cardTitle}>{card.title}</Text>}
{card.type === 'image' ? (
<ImageBody src={card.src} />
) : card.type === 'list' ? (
<ListBody md={card.md} />
) : card.type === 'map' ? (
<MapBody markers={card.markers} />
) : card.type === 'code' ? (
<CodeBody md={card.md} path={card.path} lang={card.lang} />
) : (
<Text style={styles.text}>{card.md || ''}</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: 'rgba(18,18,42,0.92)',
borderColor: 'rgba(123,92,255,0.35)',
borderWidth: 1,
borderRadius: 14,
padding: 14,
marginVertical: 8,
shadowColor: '#7B5CFF',
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 2 },
elevation: 6,
},
cardTitle: { color: '#C9C9FF', fontSize: 15, fontWeight: '700', marginBottom: 8 },
text: { color: '#E6E6F0', fontSize: 14, lineHeight: 20, flexShrink: 1 },
muted: { color: '#8A8AB0', fontSize: 13, fontStyle: 'italic' },
image: { width: '100%', height: 200, borderRadius: 8, backgroundColor: '#0D0D1A' },
listRow: { flexDirection: 'row', alignItems: 'flex-start', marginVertical: 2 },
bullet: { color: '#7B5CFF', marginRight: 8, fontSize: 14, lineHeight: 20 },
map: { backgroundColor: '#0D0D1A', borderRadius: 8, padding: 10 },
mapHint: { color: '#00B4D8', fontSize: 13, fontWeight: '600', marginBottom: 6 },
codeCaption: { color: '#8A8AB0', fontSize: 12, marginBottom: 6 },
codeScroll: { backgroundColor: '#0A0A14', borderRadius: 8, padding: 10 },
code: { color: '#B9F5C9', fontFamily: 'monospace', fontSize: 12.5, lineHeight: 18 },
});
export default React.memo(CardView);
+106
View File
@@ -0,0 +1,106 @@
/**
* Orb — ARIAs Praesenz-Avatar (M1). Zeigt ihren Zustand (idle/listening/
* thinking/speaking/working) als pulsierender Leucht-Kern und ist das
* verbindende Element ueber alle Oberflaechen (App/Web/spaeter Brille).
*
* Reine Optik, keine Logik — der Zustand kommt von aussen (aria_view.orb bzw.
* spaeter direkt von Audio/Wake-Word-Signalen). Dependency-leicht: nur
* reanimated (schon installiert), kein SVG/Gradient noetig.
*/
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { OrbState } from '../services/ariaView';
const COLORS: Record<OrbState, string> = {
idle: '#3A6EA5',
listening: '#00B4D8',
thinking: '#7B5CFF',
speaking: '#34C759',
working: '#FF9500',
};
interface Props {
state?: OrbState;
size?: number;
}
const Orb: React.FC<Props> = ({ state = 'idle', size = 120 }) => {
const pulse = useSharedValue(1);
useEffect(() => {
const fast = state === 'thinking' || state === 'working';
cancelAnimation(pulse);
pulse.value = 1;
pulse.value = withRepeat(
withTiming(fast ? 1.14 : 1.07, {
duration: fast ? 620 : 1500,
easing: Easing.inOut(Easing.ease),
}),
-1,
true,
);
return () => cancelAnimation(pulse);
}, [state, pulse]);
const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }] }));
const color = COLORS[state] || COLORS.idle;
return (
<View style={[styles.wrap, { width: size, height: size }]}>
<Animated.View
style={[
styles.glow,
{ width: size, height: size, borderRadius: size / 2, backgroundColor: color },
animStyle,
]}
/>
<Animated.View
style={[
styles.ring,
{
width: size * 0.72,
height: size * 0.72,
borderRadius: size * 0.36,
borderColor: color,
},
animStyle,
]}
/>
<View
style={[
styles.core,
{
width: size * 0.44,
height: size * 0.44,
borderRadius: size * 0.22,
backgroundColor: color,
shadowColor: color,
},
]}
/>
</View>
);
};
const styles = StyleSheet.create({
wrap: { alignItems: 'center', justifyContent: 'center' },
glow: { position: 'absolute', opacity: 0.22 },
ring: { position: 'absolute', borderWidth: 2, opacity: 0.55 },
core: {
shadowOpacity: 0.9,
shadowRadius: 16,
shadowOffset: { width: 0, height: 0 },
elevation: 12,
},
});
export default React.memo(Orb);
+92
View File
@@ -0,0 +1,92 @@
/**
* WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten.
*
* Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy-
* optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER
* gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat
* behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand.
*
* Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen).
*/
import React, { useEffect, useState } from 'react';
import { Keyboard, StyleSheet, View } from 'react-native';
import { TileId } from './layout';
import { useWorkspaceLayout } from './useWorkspaceLayout';
import WorkspaceDock from './WorkspaceDock';
import ChatTile from './tiles/ChatTile';
import FilesTile from './tiles/FilesTile';
import CodeEditorTile from './tiles/CodeEditorTile';
import DesktopTile from './tiles/DesktopTile';
interface Props {
projectId: string;
panels: TileId[];
badges?: Partial<Record<TileId, string>>;
}
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
const [active, setActive] = useState<TileId>('chat');
const [kbVisible, setKbVisible] = useState(false);
const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
// Aktives Panel pro Projekt wiederherstellen.
useEffect(() => {
if (!loaded) return;
const stored = getFocus();
if (stored && panels.includes(stored)) setActive(stored);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, loaded]);
// Falls das aktive Panel wegfaellt → erstes nehmen.
useEffect(() => {
if (!panels.includes(active)) setActive(panels[0] || 'chat');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panels.join(',')]);
useEffect(() => {
if (loaded) saveFocus(active);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, loaded]);
useEffect(() => {
const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true));
const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false));
return () => { s1.remove(); s2.remove(); };
}, []);
const render = (id: TileId) => {
switch (id) {
case 'chat': return <ChatTile />;
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
default: return null;
}
};
return (
<View style={styles.root}>
<View style={styles.stack}>
{panels.map((id) => (
<View
key={id}
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
>
{render(id)}
</View>
))}
</View>
{!kbVisible && (
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
)}
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0D0D1A' },
stack: { flex: 1, position: 'relative' },
});
export default WorkspaceDeck;
+108
View File
@@ -0,0 +1,108 @@
/**
* WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort
* das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine
* Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da".
*
* Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell
* per Daumen erreichbar — statt einer Zoom-Landkarte.
*/
import React, { useState } from 'react';
import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TileId, TILE_META } from './layout';
interface Props {
panels: TileId[];
active: TileId;
badges?: Partial<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
onSelect: (id: TileId) => void;
}
const WorkspaceDock: React.FC<Props> = ({ panels, active, badges, onSelect }) => {
const insets = useSafeAreaInsets();
const [rowW, setRowW] = useState(0);
const n = Math.max(1, panels.length);
const idx = Math.max(0, panels.indexOf(active));
const slot = rowW / n;
const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width);
const indicatorStyle = useAnimatedStyle(() => ({
width: slot,
transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }],
}));
return (
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
<View style={styles.row} onLayout={onLayout}>
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
{panels.map((id) => {
const meta = TILE_META[id];
const isActive = id === active;
const badge = badges?.[id];
return (
<TouchableOpacity
key={id}
style={styles.item}
onPress={() => onSelect(id)}
activeOpacity={0.7}
>
<View>
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
</View>
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
{meta.title}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
const styles = StyleSheet.create({
dock: {
backgroundColor: '#0B0B18',
borderTopWidth: 1,
borderTopColor: '#1E1E2E',
},
row: {
flexDirection: 'row',
height: 58,
position: 'relative',
},
indicator: {
position: 'absolute',
top: 0,
height: 3,
backgroundColor: '#0096FF',
borderBottomLeftRadius: 3,
borderBottomRightRadius: 3,
},
item: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
},
icon: { fontSize: 22, opacity: 0.55 },
iconActive: { opacity: 1 },
label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' },
labelActive: { color: '#0096FF' },
badge: {
position: 'absolute',
top: -2,
right: -6,
width: 8,
height: 8,
borderRadius: 4,
borderWidth: 1,
borderColor: '#0B0B18',
},
});
export default WorkspaceDock;
+102
View File
@@ -0,0 +1,102 @@
/**
* WorkspaceScreen — Screen-Wrapper fuer die Workbench.
*
* Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau).
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
*
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { View } from 'react-native';
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
import codeFile from '../services/codeFile';
import brainApi from '../services/brainApi';
import viewMode, { ViewModeValue } from '../services/viewMode';
import ChatScreen from '../screens/ChatScreen';
import { TileId } from './layout';
import WorkspaceDeck from './WorkspaceDeck';
import ariaView, { AriaView } from '../services/ariaView';
import AriaViewCanvas from './AriaViewCanvas';
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
const WorkspaceScreen: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
const [focus, setFocus] = useState<FocusSnapshot>(projectFocus.get());
const [hasCode, setHasCode] = useState(false);
const [hasDesktop, setHasDesktop] = useState(false);
const [view, setView] = useState<AriaView | undefined>(undefined);
useEffect(() => viewMode.subscribe(setMode), []);
useEffect(() => projectFocus.subscribe(setFocus), []);
const pid = focus.focusedProjectId;
// aria_view: ARIAs komponierte Ansicht fuers fokussierte Projekt spiegeln.
useEffect(() => {
setView(ariaView.getView(pid));
return ariaView.subscribe((v) => {
if ((v.projectId || '') === (pid || '')) setView(v);
});
}, [pid]);
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
useEffect(() => {
setHasCode(codeFile.getFiles(pid).length > 0);
return codeFile.subscribe((u) => {
if ((u.projectId || '') === (pid || '')) setHasCode(true);
});
}, [pid]);
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
useEffect(() => {
if (!pid) { setHasDesktop(false); return; }
let alive = true;
const check = () => {
brainApi.listProjectVms(pid)
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
.catch(() => { if (alive) setHasDesktop(false); });
};
check();
const t = setInterval(check, 6000);
return () => { alive = false; clearInterval(t); };
}, [pid]);
const badges = useMemo(() => ({
editor: hasCode ? '#0096FF' : undefined,
vnc: hasDesktop ? '#34C759' : undefined,
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
// Kompakt-Ansicht: klassischer Vollbild-Chat; Cockpit: Workbench mit Dock.
const content =
mode === 'compact' ? (
<ChatScreen />
) : (
<WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />
);
// Generative Flaeche als Overlay, sobald ARIA fuer dieses Projekt eine Ansicht
// komponiert hat (present_view → aria_view). Chat/Cockpit bleiben darunter.
const showView = !!view && (view.projectId || '') === (pid || '');
return (
<View style={{ flex: 1 }}>
{content}
{showView && view && (
<AriaViewCanvas
view={view.view}
onClose={() => {
ariaView.clear(pid);
setView(undefined);
}}
/>
)}
</View>
);
};
export default WorkspaceScreen;
+167
View File
@@ -0,0 +1,167 @@
/**
* editorHtml — selbstenthaltener Live-Code-Editor fuer die WebView (offline,
* kein CDN/Bundler). Eine transparente <textarea> ueber einer <pre>-Highlight-
* Ebene: man sieht Syntax-Highlighting UND kann tippen. Bewusst leichtgewichtig
* (Regex-Highlighter fuer C-artige/JS/Python/Shell), damit es ohne Build-Schritt
* inline passt.
*
* Bridge-Protokoll:
* RN -> WebView window.ariaBridge.onMessage(jsonString):
* {cmd:'setContent', content, language, version}
* {cmd:'applyPatch', from, to, insert, version}
* {cmd:'setLanguage', language}
* {cmd:'setReadOnly', value}
* WebView -> RN window.ReactNativeWebView.postMessage(jsonString):
* {event:'ready'}
* {event:'onEditFromUser', from, to, insert, fullText, version}
*/
export const EDITOR_HTML = `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #0D0D1A; }
#wrap { position: relative; height: 100%; width: 100%; }
#hl, #ed {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
margin: 0; border: 0; padding: 10px 12px;
font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.45;
white-space: pre; word-wrap: normal; overflow: auto; tab-size: 2;
}
#hl { color: #C8C8E0; z-index: 1; pointer-events: none; }
#ed {
z-index: 2; color: transparent; background: transparent; caret-color: #0096FF;
resize: none; outline: none;
-webkit-text-fill-color: transparent;
}
#ed::selection { background: rgba(0,150,255,0.3); }
.tok-cmt { color: #6A7A6A; font-style: italic; }
.tok-str { color: #C6A972; }
.tok-num { color: #B58BE0; }
.tok-kw { color: #4F9CE8; font-weight: bold; }
</style></head><body>
<div id="wrap">
<pre id="hl"></pre>
<textarea id="ed" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<script>
(function(){
var ed = document.getElementById('ed');
var hl = document.getElementById('hl');
var lang = 'text';
var version = 0;
var lastValue = '';
var applyingProgrammatic = false;
var KW = {
common: ['if','else','for','while','do','return','break','continue','switch','case','default','function','var','let','const','class','new','this','import','from','export','try','catch','finally','throw','typeof','instanceof','void','delete','in','of','yield','async','await','def','elif','end','then','fi','esac','local','echo','extends','implements','interface','public','private','protected','static','struct','enum','include','define','null','true','false','undefined','None','True','False','print','with','as','pass','lambda','not','and','or','is']
};
function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function highlight(code){
// Token-Scan: Kommentare, Strings, Zahlen, Keywords. Bewusst simpel.
var out = '';
var i = 0, n = code.length;
var kwRe = /[A-Za-z_][A-Za-z0-9_]*/;
while(i < n){
var c = code[i];
var two = code.substr(i,2);
// Zeilenkommentar // oder #
if(two === '//' || (c === '#')){
var j = code.indexOf('\\n', i); if(j<0) j=n;
out += '<span class="tok-cmt">'+esc(code.slice(i,j))+'</span>'; i=j; continue;
}
// Blockkommentar
if(two === '/*'){
var k = code.indexOf('*/', i+2); k = (k<0)? n : k+2;
out += '<span class="tok-cmt">'+esc(code.slice(i,k))+'</span>'; i=k; continue;
}
// Strings
if(c === '"' || c === "'" || c === '\`'){
var q=c, m=i+1;
while(m<n){ if(code[m]==='\\\\'){m+=2;continue;} if(code[m]===q){m++;break;} m++; }
out += '<span class="tok-str">'+esc(code.slice(i,m))+'</span>'; i=m; continue;
}
// Zahl
if(c>='0' && c<='9'){
var p=i+1; while(p<n && /[0-9a-fA-F.xX_]/.test(code[p])) p++;
out += '<span class="tok-num">'+esc(code.slice(i,p))+'</span>'; i=p; continue;
}
// Wort / Keyword
if(/[A-Za-z_]/.test(c)){
var rest = code.slice(i);
var mm = rest.match(kwRe);
var w = mm[0];
if(KW.common.indexOf(w) >= 0){ out += '<span class="tok-kw">'+esc(w)+'</span>'; }
else { out += esc(w); }
i += w.length; continue;
}
out += esc(c); i++;
}
return out;
}
function render(){
hl.innerHTML = highlight(ed.value) + '\\n';
hl.scrollTop = ed.scrollTop; hl.scrollLeft = ed.scrollLeft;
}
function post(obj){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(obj)); }
// Minimalen Diff (gemeinsamer Prefix/Suffix) zwischen alt und neu.
function diff(a, b){
var s = 0; var maxS = Math.min(a.length, b.length);
while(s < maxS && a[s] === b[s]) s++;
var e = 0;
while(e < (maxS - s) && a[a.length-1-e] === b[b.length-1-e]) e++;
return { from: s, to: a.length - e, insert: b.slice(s, b.length - e) };
}
var editTimer = null;
ed.addEventListener('input', function(){
render();
if(applyingProgrammatic) return;
if(editTimer) clearTimeout(editTimer);
editTimer = setTimeout(function(){
var nv = ed.value;
var d = diff(lastValue, nv);
lastValue = nv; version++;
post({ event:'onEditFromUser', from:d.from, to:d.to, insert:d.insert, fullText:nv, version:version });
}, 160);
});
ed.addEventListener('scroll', function(){ hl.scrollTop=ed.scrollTop; hl.scrollLeft=ed.scrollLeft; });
window.ariaBridge = {
onMessage: function(json){
var m; try { m = JSON.parse(json); } catch(e){ return; }
if(m.cmd === 'setContent'){
applyingProgrammatic = true;
ed.value = m.content || '';
lastValue = ed.value;
if(typeof m.version === 'number') version = m.version;
if(m.language) lang = m.language;
render();
applyingProgrammatic = false;
} else if(m.cmd === 'applyPatch'){
applyingProgrammatic = true;
var v = ed.value;
var from = Math.max(0, Math.min(m.from, v.length));
var to = Math.max(from, Math.min(m.to, v.length));
ed.value = v.slice(0, from) + (m.insert||'') + v.slice(to);
lastValue = ed.value;
if(typeof m.version === 'number') version = m.version;
render();
applyingProgrammatic = false;
} else if(m.cmd === 'setLanguage'){
lang = m.language || 'text'; render();
} else if(m.cmd === 'setReadOnly'){
ed.readOnly = !!m.value;
}
}
};
render();
post({ event:'ready' });
})();
</script></body></html>`;
+134
View File
@@ -0,0 +1,134 @@
/**
* novncHtml — noVNC-Client fuer die WebView, dessen WebSocket durch den
* RVS-Tunnel gebrueckt wird.
*
* Trick: window.WebSocket wird VOR dem Laden von noVNC durch einen Shim
* ersetzt. noVNC (RFB) glaubt, ein echtes WebSocket zu benutzen; tatsaechlich
* gehen die RFB-Bytes als Base64 per postMessage an RN → RVS → Bridge → QEMU
* (und zurueck). Da RFB "server-speaks-first" ist, ist die Reihenfolge robust.
*
* noVNC wird vom CDN geladen (das Telefon hat Internet, da es ohnehin am RVS
* haengt). Voll-offline-Bundling waere ein spaeterer Schritt.
*
* Protokoll:
* RN -> WebView window.ariaVnc.onData(b64) RFB-Bytes vom Server
* WebView -> RN {event:'ready'} RFB initialisiert → Tunnel oeffnen
* {event:'vnc_send', b64} RFB-Bytes an den Server
* {event:'vnc_close'} RFB hat geschlossen
* {event:'vnc_state', state} connected|disconnected
*/
export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
* { margin:0; padding:0; }
html, body { height:100%; background:#000; overflow:hidden; }
#screen { width:100%; height:100%; }
#msg { position:absolute; top:8px; left:0; right:0; text-align:center;
color:#9090B0; font-family:sans-serif; font-size:12px; pointer-events:none; }
</style></head><body>
<div id="screen"></div>
<div id="msg">Verbinde mit Desktop …</div>
<script>
(function(){
function post(o){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(o)); }
function b64FromBytes(bytes){
var CHUNK=0x8000, parts=[];
for(var i=0;i<bytes.length;i+=CHUNK){ parts.push(String.fromCharCode.apply(null, bytes.subarray(i,i+CHUNK))); }
return btoa(parts.join(''));
}
function bytesFromB64(b64){
var s=atob(b64), a=new Uint8Array(s.length);
for(var i=0;i<s.length;i++) a[i]=s.charCodeAt(i);
return a;
}
// --- WebSocket-Shim ---
function BridgeSocket(url, protocols){
this.url=url; this.protocol=''; this.readyState=0; this.binaryType='arraybuffer';
this.onopen=null; this.onclose=null; this.onerror=null; this.onmessage=null;
var self=this; window.__vncSocket=self;
setTimeout(function(){ self.readyState=1; if(self.onopen) self.onopen({type:'open'}); }, 0);
}
BridgeSocket.CONNECTING=0; BridgeSocket.OPEN=1; BridgeSocket.CLOSING=2; BridgeSocket.CLOSED=3;
BridgeSocket.prototype.send=function(data){
var bytes;
if(data instanceof ArrayBuffer) bytes=new Uint8Array(data);
else if(ArrayBuffer.isView(data)) bytes=new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
else bytes=new Uint8Array(0);
post({event:'vnc_send', b64:b64FromBytes(bytes)});
};
BridgeSocket.prototype.close=function(){
if(this.readyState===3) return;
this.readyState=3; if(this.onclose) this.onclose({type:'close'}); post({event:'vnc_close'});
};
BridgeSocket.prototype.addEventListener=function(t,fn){ this['on'+t]=fn; };
BridgeSocket.prototype.removeEventListener=function(t){ this['on'+t]=null; };
window.WebSocket = BridgeSocket;
// Eingehende Server-Bytes → in den Shim einspeisen.
window.ariaVnc = {
onData:function(b64){
var sock=window.__vncSocket;
if(!sock || !sock.onmessage) return;
sock.onmessage({ type:'message', data: bytesFromB64(b64).buffer });
}
};
var msg=document.getElementById('msg');
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
// cp<0x100 → Keysym == Codepoint (Latin-1)
// sonst → X11-Unicode-Keysym 0x01000000+cp
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
var RFB = mod.default;
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
var fit=true;
rfb.scaleViewport = true;
rfb.clipViewport = false;
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
rfb.addEventListener('disconnect', function(e){
msg.style.display='block'; msg.textContent='Desktop getrennt';
post({event:'vnc_state', state:'disconnected'});
});
window.__rfb = rfb;
// Down+Up einer Taste an die VM schicken.
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
// per injectJavaScript.
// char(cp) druckbares Zeichen (Codepoint)
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
window.ariaVncKey = {
char: function(cp){ tap(cpToKeysym(cp)); },
keysym: function(ks){ tap(ks); },
combo: function(mods, ks){
try{
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
}catch(_){}
}
};
// Steuerungs-API fuer die App (per injectJavaScript).
window.ariaVncCtl = {
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
};
post({event:'ready'});
}).catch(function(err){
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
post({event:'vnc_state', state:'error', error:String(err)});
});
})();
</script></body></html>`;
+15
View File
@@ -0,0 +1,15 @@
/**
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
*/
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
export interface TileDef { id: TileId; title: string; icon: string }
export const TILE_META: Record<TileId, TileDef> = {
chat: { id: 'chat', title: 'Chat', icon: '💬' },
files: { id: 'files', title: 'Dateien', icon: '📁' },
editor: { id: 'editor', title: 'Code', icon: '📝' },
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
};
+15
View File
@@ -0,0 +1,15 @@
/**
* ChatTile — hostet die bestehende ChatScreen unveraendert als Workspace-Kachel.
*
* ChatScreen bleibt genau EINE Instanz (der Workspace-Tab ersetzt den alten
* Chat-Tab) und wird nie beim Fokuswechsel remountet — sie liegt in der
* Identity-Content-Ebene und wird nur per display ein-/ausgeblendet. So
* behaelt sie RVS-Abos, Audio, Queue-State und Keyboard-Verhalten wie bisher.
*/
import React from 'react';
import ChatScreen from '../../screens/ChatScreen';
const ChatTile: React.FC = () => <ChatScreen />;
export default React.memo(ChatTile);
@@ -0,0 +1,168 @@
/**
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
*
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
* ARIA schon Dateien geschrieben hat.
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import codeFile from '../../services/codeFile';
import brainApi from '../../services/brainApi';
import { EDITOR_HTML } from '../assets/editorHtml';
interface Props {
projectId: string;
}
function guessLang(path: string): string {
const ext = (path.split('.').pop() || '').toLowerCase();
const map: Record<string, string> = {
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
};
return map[ext] || 'text';
}
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
const webRef = useRef<WebView>(null);
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
const [serverPaths, setServerPaths] = useState<string[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [loadErr, setLoadErr] = useState<string>('');
const readyRef = useRef(false);
const currentPathRef = useRef<string | null>(currentPath);
currentPathRef.current = currentPath;
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
const files = useMemo(() => {
const set = new Set<string>(serverPaths);
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [serverPaths, projectId]);
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
webRef.current?.injectJavaScript(js);
}, []);
const loadFileIntoEditor = useCallback(async (path: string | null) => {
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
const live = codeFile.getFile(projectId, path);
if (live) {
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
return;
}
try {
const res = await brainApi.readProjectFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
} catch (e: any) {
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
}
}, [projectId, sendToWeb]);
// Projektwechsel: vorhandene Dateien vom Brain laden.
useEffect(() => {
let cancelled = false;
setLoadErr('');
brainApi.listProjectFiles(projectId)
.then(res => {
if (cancelled) return;
const paths = (res.files || []).map(f => f.path);
setServerPaths(paths);
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
})
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
return () => { cancelled = true; };
}, [projectId]);
// Live-Updates aus dem Spiegel.
useEffect(() => {
return codeFile.subscribe((u) => {
if ((u.projectId || '') !== (projectId || '')) return;
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
if (u.path !== currentPathRef.current || !readyRef.current) return;
if (u.patch) {
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
} else {
sendToWeb({ cmd: 'setContent', content: u.content ?? '', language: u.language, version: u.version });
}
});
}, [projectId, sendToWeb]);
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
useEffect(() => {
if (readyRef.current) loadFileIntoEditor(currentPath);
}, [currentPath, loadFileIntoEditor]);
const onMessage = useCallback((e: WebViewMessageEvent) => {
let m: any;
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
if (m.event === 'ready') {
readyRef.current = true;
loadFileIntoEditor(currentPathRef.current);
} else if (m.event === 'onEditFromUser') {
const path = currentPathRef.current;
if (!path) return;
codeFile.sendEdit(projectId, path, { from: m.from, to: m.to, insert: m.insert }, m.fullText, m.version);
}
}, [projectId, loadFileIntoEditor]);
return (
<View style={styles.container}>
<View style={styles.tabsRow}>
{files.length === 0 ? (
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{files.map((path) => {
const active = path === currentPath;
const name = path.split('/').pop() || path;
return (
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
</View>
<WebView
ref={webRef}
style={styles.web}
originWhitelist={['*']}
source={{ html: EDITOR_HTML, baseUrl: '' }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
keyboardDisplayRequiresUserAction={false}
androidLayerType="hardware"
setBuiltInZoomControls={false}
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
tabsRow: { height: 40, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1, justifyContent: 'center' },
tabs: { alignItems: 'center', paddingHorizontal: 6 },
noFiles: { color: '#9090B0', fontSize: 13, paddingHorizontal: 12 },
tab: { paddingHorizontal: 12, paddingVertical: 6, marginHorizontal: 3, borderRadius: 12, backgroundColor: '#0D0D1A', maxWidth: 180 },
tabActive: { backgroundColor: '#0096FF' },
tabText: { color: '#9090B0', fontSize: 12, fontWeight: '600' },
tabTextActive: { color: '#FFFFFF' },
web: { flex: 1, backgroundColor: '#0D0D1A' },
});
export default CodeEditorTile;
+192
View File
@@ -0,0 +1,192 @@
/**
* DesktopTile — das Desktop-Panel eines Code-Projekts.
*
* Zeigt die (pro Projekt gefuehrte) QEMU-VM-Liste: leer, bis ARIA per
* vm_register eine VM eintraegt. Pro VM: Start / Stop / Verbinden. „Verbinden"
* oeffnet die noVNC-Ansicht (VncTile) fuer den VNC-Port dieser VM.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi, { ProjectVm } from '../../services/brainApi';
import VncTile from './VncTile';
interface Props {
projectId: string;
focused: boolean;
}
const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
const [vms, setVms] = useState<ProjectVm[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
const [connected, setConnected] = useState<ProjectVm | null>(null);
const [shotBusy, setShotBusy] = useState('');
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
const load = useCallback(() => {
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectVms(projectId)
.then(r => setVms(r.vms || []))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => {
if (focused && !connected) load();
}, [focused, projectId, connected, load]);
const boot = useCallback((vm: ProjectVm) => {
setBusy(vm.name);
brainApi.bootProjectVm(projectId, vm.name)
.then(() => load())
.catch(e => setErr(String(e?.message || e)))
.finally(() => setBusy(''));
}, [projectId, load]);
const stop = useCallback((vm: ProjectVm) => {
setBusy(vm.name);
brainApi.stopProjectVm(projectId, vm.name)
.then(() => load())
.catch(e => setErr(String(e?.message || e)))
.finally(() => setBusy(''));
}, [projectId, load]);
const screenshot = useCallback((vm: ProjectVm) => {
setShotBusy(vm.name); setErr('');
brainApi.screenshotProjectVm(projectId, vm.name)
.then(r => setShot({ name: vm.name, b64: r.base64 }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setShotBusy(''));
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Panel öffnen für VM-Liste</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
<Text style={styles.barTitle}>Virtuelle Maschinen</Text>
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}></Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 12 }}>
{loading && vms.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : !projectId ? (
<Text style={styles.empty}>Kein aktives Projekt wechsle in ein Projekt für dessen VMs.</Text>
) : vms.length === 0 ? (
<Text style={styles.empty}>
Noch keine VM in diesem Projekt.{'\n'}
Sag ARIA z.B. bau eine QEMU-VM zum Testen" — sie registriert sie hier,
dann kannst du sie starten und verbinden.
</Text>
) : (
vms.map(vm => {
const isBusy = busy === vm.name;
return (
<View key={vm.name} style={styles.vmRow}>
<View style={{ flex: 1 }}>
<Text style={styles.vmName}>
<Text style={{ color: vm.running ? '#34C759' : '#555570' }}>●</Text> {vm.name}
<Text style={styles.vmMeta}> {vm.arch} · {vm.running ? 'läuft' : 'gestoppt'}</Text>
</Text>
<Text style={styles.vmCmd} numberOfLines={2}>{vm.boot_cmd || `aria-vm boot ${vm.name} --vnc-display ${vm.vnc_display}`}</Text>
</View>
<View style={styles.vmBtns}>
{isBusy ? (
<ActivityIndicator color="#0096FF" />
) : vm.running ? (
<>
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
{shotBusy === vm.name
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => stop(vm)} style={[styles.vmBtn, { borderColor: '#E55C5C' }]}>
<Text style={[styles.vmBtnText, { color: '#E55C5C' }]}>Stop</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity onPress={() => boot(vm)} style={[styles.vmBtn, { borderColor: '#34C759' }]}>
<Text style={[styles.vmBtnText, { color: '#34C759' }]}>Start</Text>
</TouchableOpacity>
)}
</View>
</View>
);
})
)}
</ScrollView>
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
{shot && (
<Image
source={{ uri: `data:image/png;base64,${shot.b64}` }}
style={styles.shotImg}
resizeMode="contain"
/>
)}
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
</TouchableOpacity>
</Modal>
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
{connected && (
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
<View style={styles.fs}>
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
<Text style={styles.fsBackText}> VMs</Text>
</TouchableOpacity>
</View>
</Modal>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
placeholder: { flex: 1, backgroundColor: '#000', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 64, marginBottom: 16 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
empty: { color: '#8888AA', fontSize: 13, lineHeight: 20, textAlign: 'center', marginTop: 24 },
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16 },
vmRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#12122A', borderRadius: 10, padding: 12, marginBottom: 8 },
vmName: { color: '#E0E0F0', fontSize: 15, fontWeight: '700' },
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
vmBtnText: { fontSize: 12, fontWeight: '700' },
fs: { flex: 1, backgroundColor: '#000000' },
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 },
});
export default DesktopTile;
+149
View File
@@ -0,0 +1,149 @@
/**
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
*
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi from '../../services/brainApi';
interface Props {
projectId: string;
focused: boolean;
}
interface FileEntry { path: string; size: number }
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
function iconFor(path: string): string {
const e = ext(path);
if (isImage(path)) return '🖼️';
if (['md', 'txt', 'readme'].includes(e)) return '📄';
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
return '📄';
}
function humanSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
const [previewBusy, setPreviewBusy] = useState('');
const load = useCallback(() => {
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectFiles(projectId)
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
const open = useCallback((f: FileEntry) => {
setPreviewBusy(f.path); setErr('');
if (isImage(f.path)) {
brainApi.readProjectFileBinary(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
} else {
brainApi.readProjectFile(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
}
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>📁</Text>
<Text style={styles.text}>Dateien</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}></Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 8 }}>
{loading && files.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : files.length === 0 ? (
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
) : (
files.map(f => (
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
{previewBusy === f.path
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
</TouchableOpacity>
))
)}
</ScrollView>
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
<View style={styles.pvOverlay}>
<View style={styles.pvBar}>
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}></Text></TouchableOpacity>
</View>
{preview?.kind === 'image' ? (
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
) : (
<ScrollView style={styles.pvTextWrap} horizontal>
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
</ScrollView>
)}
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 56, marginBottom: 10 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
rowIcon: { fontSize: 18 },
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
rowSize: { color: '#555570', fontSize: 11 },
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
pvImg: { flex: 1, width: '100%' },
pvTextWrap: { flex: 1, padding: 12 },
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
});
export default FilesTile;
+298
View File
@@ -0,0 +1,298 @@
/**
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
*
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
* dem Handy voll bedienbar:
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1F12 und
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import desktop from '../../services/desktop';
import { NOVNC_HTML } from '../assets/novncHtml';
interface Props {
projectId: string;
focused: boolean;
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
}
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
const NAV_KEYS: { label: string; ks: number }[] = [
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
];
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
const webRef = useRef<WebView>(null);
const kbdRef = useRef<TextInput>(null);
const bufRef = useRef(''); // Spiegel des TextInput-Textes
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
const [kbdOn, setKbdOn] = useState(false);
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
const unsubDataRef = useRef<null | (() => void)>(null);
const teardown = useCallback(() => {
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
desktop.closeVnc();
}, []);
useEffect(() => {
if (!focused) { teardown(); setStatus('idle'); }
return () => teardown();
}, [focused, teardown]);
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
useEffect(() => {
if (!focused) return;
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
return () => { show.remove(); hide.remove(); };
}, [focused]);
const ctl = useCallback((fn: string) => {
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
}, []);
const sendKeysym = useCallback((ks: number) => {
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
}, []);
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
}, []);
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
const activeMods = useCallback(() => {
const m = modRef.current; const a: number[] = [];
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
return a;
}, []);
const clearMods = useCallback(() => {
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
modRef.current = { ctrl: false, alt: false, shift: false };
setMods(modRef.current);
}
}, []);
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
setMods(modRef.current);
}, []);
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
const pressKey = useCallback((ks: number) => {
const m = activeMods();
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
}, [activeMods, sendCombo, clearMods, sendKeysym]);
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
const pressChar = useCallback((cp: number) => {
const m = activeMods();
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
}, [activeMods, sendCombo, clearMods]);
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
const toggleKbd = useCallback(() => {
if (kbdOn) { Keyboard.dismiss(); }
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
}, [kbdOn]);
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
const text = e.nativeEvent.text || '';
const prev = bufRef.current;
let i = 0;
const min = Math.min(prev.length, text.length);
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
bufRef.current = text;
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
}, [pressChar]);
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const k = e.nativeEvent.key;
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
else if (k === 'Enter') pressKey(KEYSYM.Enter);
}, [pressKey]);
const onMessage = useCallback((e: WebViewMessageEvent) => {
let m: any;
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
if (m.event === 'ready') {
setStatus('connecting');
unsubDataRef.current = desktop.onVncData((b64) => {
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
webRef.current?.injectJavaScript(js);
});
desktop.openVnc(projectId, port);
} else if (m.event === 'vnc_send') {
desktop.sendInput(m.b64);
} else if (m.event === 'vnc_close') {
desktop.closeVnc();
} else if (m.event === 'vnc_state') {
if (m.state === 'connected') setStatus('connected');
else if (m.state === 'disconnected') setStatus('disconnected');
}
}, [projectId, port]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
</View>
);
}
const connected = status === 'connected';
return (
<View style={styles.container}>
<WebView
ref={webRef}
style={styles.web}
originWhitelist={['*']}
source={{ html: NOVNC_HTML, baseUrl: 'https://aria-vnc.local/' }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
mixedContentMode="always"
androidLayerType="hardware"
keyboardDisplayRequiresUserAction={false}
/>
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
<TextInput
ref={kbdRef}
style={styles.hiddenInput}
onChange={onKbdChange}
onKeyPress={onKbdKeyPress}
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
keyboardType="visible-password"
returnKeyType="send"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
blurOnSubmit={false}
caretHidden
contextMenuHidden
multiline={false}
/>
{/* Steuerungs-Leiste — nur wenn verbunden */}
{connected && (
<View style={styles.ctlBar}>
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
<Text style={styles.ctlText}>Fn</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
</TouchableOpacity>
</View>
)}
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
{connected && keyBar && (
<View style={styles.keyBar} pointerEvents="box-none">
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
{NAV_KEYS.map(k => (
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
))}
</ScrollView>
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
{F_KEYS.map(k => (
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
))}
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
</ScrollView>
</View>
)}
{!connected && (
<View style={styles.overlay} pointerEvents="none">
<Text style={styles.overlayText}>
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
web: { flex: 1, backgroundColor: '#000000' },
placeholder: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 64, marginBottom: 16 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
ctlBar: {
position: 'absolute',
top: 34,
right: 8,
flexDirection: 'row',
gap: 6,
},
ctlBtn: {
backgroundColor: 'rgba(18,18,42,0.9)',
borderColor: '#2A2A3E',
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 7,
minWidth: 38,
alignItems: 'center',
justifyContent: 'center',
},
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
key: {
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
alignItems: 'center', justifyContent: 'center',
},
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
});
export default VncTile;
@@ -0,0 +1,40 @@
/**
* useWorkspaceLayout — merkt sich pro Projekt die zuletzt fokussierte Kachel,
* damit man beim Zurueckkehren in ein Code-Projekt wieder dort landet (Editor/
* Desktop) statt immer im Chat. Persistiert nach AsyncStorage (Muster wie
* aria_project_drafts).
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useCallback, useEffect, useRef, useState } from 'react';
import { TileId } from './layout';
const KEY = 'aria_workspace_layout';
interface Entry { focus: TileId | null }
type LayoutMap = Record<string, Entry>;
const keyOf = (projectId: string) => projectId || '__main__';
export function useWorkspaceLayout(projectId: string) {
const mapRef = useRef<LayoutMap>({});
const [loaded, setLoaded] = useState(false);
useEffect(() => {
AsyncStorage.getItem(KEY).then((v) => {
if (v) { try { mapRef.current = JSON.parse(v) || {}; } catch { /* ignore */ } }
setLoaded(true);
}).catch(() => setLoaded(true));
}, []);
const getFocus = useCallback((): TileId | null | undefined => {
return mapRef.current[keyOf(projectId)]?.focus;
}, [projectId]);
const saveFocus = useCallback((focus: TileId | null) => {
mapRef.current = { ...mapRef.current, [keyOf(projectId)]: { focus } };
AsyncStorage.setItem(KEY, JSON.stringify(mapRef.current)).catch(() => {});
}, [projectId]);
return { loaded, getFocus, saveFocus };
}
+42
View File
@@ -0,0 +1,42 @@
# ════════════════════════════════════════════════════════════
# ARIA Brain — Agent + Memory Container
#
# FastAPI-Server mit Vector-DB-Memory (Qdrant).
# Spricht via HTTP/WebSocket mit Bridge und Diagnostic.
# LLM-Calls gehen ueber den Proxy (claude-max-api-proxy).
# ════════════════════════════════════════════════════════════
FROM python:3.12-slim
# System-Tools die Skills brauchen koennten (curl, jq, git, ssh-client,
# Build-Basics fuer venv-Compiles). Bewusst sparsam — alles weitere
# bringt der Skill selbst mit (siehe execution=local-bin).
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
jq \
git \
openssh-client \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
RUN pip install --no-cache-dir torch==2.5.1 \
--index-url https://download.pytorch.org/whl/cpu
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Embedding-Model-Cache und Skills landen unter /data (Volume)
ENV SENTENCE_TRANSFORMERS_HOME=/data/_models
ENV ARIA_DATA_DIR=/data
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+3070
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
"""
Background-Loop fuer Triggers.
Laeuft alle TICK_SEC Sekunden in einem asyncio Task, geht ueber alle
active Triggers und entscheidet ob sie feuern muessen.
Feuern bedeutet:
1. Trigger-Manifest update (fire_count++, last_fired_at, ggf. deaktivieren)
2. Log-Eintrag schreiben
3. agent.chat() mit einem system-Praefix aufrufen (NICHT als 'user'!)
→ ARIA bekommt das wie eine Push-Nachricht und kann antworten
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Optional
import triggers as triggers_mod
import watcher as watcher_mod
logger = logging.getLogger(__name__)
# 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")
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
Synchron via urllib — wird per run_in_executor aus dem async-Loop
gerufen. Failures werden geloggt, brechen aber nicht ab.
"""
payload = json.dumps({
"reply": reply,
"trigger_name": trigger_name,
"type": ttype,
"events": events or [],
}).encode("utf-8")
url = f"{BRIDGE_URL}/internal/trigger-fired"
try:
req = urllib.request.Request(
url, data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
logger.warning("[trigger-push] Bridge hat %s zurueckgegeben", resp.status)
except urllib.error.URLError as exc:
logger.warning("[trigger-push] Bridge unerreichbar (%s): %s", url, exc)
except Exception as exc:
logger.warning("[trigger-push] Push fehlgeschlagen: %s", exc)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(s: str) -> Optional[datetime]:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def _should_fire(trigger: dict, vars_: dict, now: datetime) -> bool:
if not trigger.get("active", True):
return False
t = trigger.get("type", "")
if t == "timer":
fires_at = _parse_iso(trigger.get("fires_at", ""))
if not fires_at:
return False
if fires_at.tzinfo is None:
fires_at = fires_at.replace(tzinfo=timezone.utc)
return now >= fires_at
if t == "watcher":
# Check-Interval respektieren (sonst pollen wir zu hektisch)
check_interval = int(trigger.get("check_interval_sec", 300))
last_checked = _parse_iso(trigger.get("last_checked_at", ""))
if last_checked:
if last_checked.tzinfo is None:
last_checked = last_checked.replace(tzinfo=timezone.utc)
if (now - last_checked).total_seconds() < check_interval:
return False
# Throttle: erst feuern wenn last_fired lange genug her ist
last_fired = _parse_iso(trigger.get("last_fired_at", ""))
throttle = int(trigger.get("throttle_sec", 3600))
if last_fired:
if last_fired.tzinfo is None:
last_fired = last_fired.replace(tzinfo=timezone.utc)
if (now - last_fired).total_seconds() < throttle:
return False
# Condition pruefen
cond = (trigger.get("condition") or "").strip()
if not cond:
return False
try:
return watcher_mod.evaluate(cond, vars_)
except Exception as e:
logger.warning("Trigger %s: Condition '%s' fehlerhaft: %s",
trigger.get("name"), cond, e)
return False
if t == "cron":
# TODO: später, wenn jemand Bock auf Cron-Parser hat
return False
return False
async def _fire(trigger: dict, agent_factory) -> None:
"""Ruft ARIA mit einer System-Praefix-Nachricht auf."""
name = trigger.get("name", "?")
message = trigger.get("message") or "(ohne Nachricht)"
ttype = trigger.get("type", "?")
# Manifest updaten
try:
triggers_mod.mark_fired(name)
except Exception as e:
logger.warning("mark_fired %s: %s", name, e)
# Log
triggers_mod.append_log(name, {"event": "fired", "type": ttype, "message": message})
# System-Nachricht an ARIA: nicht als User, sondern als Hinweis
prompt = (
f"[Trigger ausgelöst: '{name}', Typ: {ttype}] "
f"Geplante Nachricht: \"{message}\". "
f"Sage Stefan jetzt diese Information, in deinem Stil. "
f"Wenn der Trigger ein Watcher war (Bedingung wurde erfuellt), "
f"erwaehne kurz worum es geht. Antworte direkt, keine Rueckfrage."
)
try:
# WICHTIG: agent.chat() ist ein SYNCHRONER, blockierender Aufruf (Proxy-
# HTTP mit bis zu 24h Read-Timeout). NIEMALS direkt im async-Loop —
# sonst friert ein einziger getriggerter Turn den GESAMTEN Brain ein
# (kein /health, kein weiterer Request). Wie der /chat-Pfad in den
# Executor auslagern, damit der Event-Loop frei bleibt.
loop = asyncio.get_running_loop()
def _run_turn():
a = agent_factory()
rep, *_rest = a.chat(prompt, source="trigger")
return rep, a.pop_events()
reply, events = await loop.run_in_executor(None, _run_turn)
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
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:
logger.warning("triggers.list: %s", e)
return
if not all_triggers:
return
now = datetime.now(timezone.utc)
for trigger in all_triggers:
if trigger.get("type") != "watcher":
continue
try:
# 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:
await _tick(agent_factory)
except Exception as e:
logger.exception("Tick-Fehler: %s", e)
await asyncio.sleep(TICK_SEC)
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Einmal-Cleanup: entfernt "vergiftete" Hauptthread-Turns aus conversation.jsonl.
Hintergrund
-----------
Solange ARIAs Persona nur via --append-system-prompt kam (statt --system-prompt,
voller Replace), fiel das Modell im Hauptchat aus der Rolle und antwortete als
"Claude Code" ("das ist injizierter Kontext, ich adoptiere die Persona nicht").
Jede dieser Antworten wurde per conversation.add("assistant", ...) in die History
geschrieben. Beim naechsten Request landet sie als <previous_response> im
stdin-Prompt — das Modell sieht seine EIGENEN Ablehnungs-Turns und setzt die
Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
zieht die History das Modell weiter aus der Rolle.
Was das Script tut
------------------
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
legitime Hauptchat-Turns.
- Standard = DRY-RUN (zeigt nur was raus wuerde). Mit --apply wird geschrieben,
vorher ein Backup .pre-cleanup.bak angelegt. Idempotent.
Aufruf (auf der VM, Host-Pfad des Bind-Mounts):
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl --apply
Danach Brain neu starten, damit die bereinigte History geladen wird:
docker compose restart aria-brain
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
# STARKE, selbstreferenzielle Break-Marker — identisch zu prompts._IDENTITY_BREAK
# (dem Laufzeit-Gift-Waechter). Hier dupliziert, damit das Script self-contained
# ist (laeuft auch auf dem Host-Python ohne qdrant/prompts-Import). Bewusst NICHT
# das blosse Wort "injizier"/"prompt injection" — das nutzt ARIA in Pentest-
# Antworten legitim (sonst False Positives auf echte Security-Doku, wie im
# Dry-Run gesehen: "Runde 60 … SSRF", "Dein Ziel: LLM …").
_BREAK = re.compile(
r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|"
r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|"
r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|"
r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|"
r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|"
r"injected\s+(?:system\s*prompt|persona|context)|"
r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|"
r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|"
r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this",
re.IGNORECASE,
)
def is_poison(content: str) -> bool:
return bool(_BREAK.search(content or ""))
def get_content(obj: dict) -> str:
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
v = obj.get("content")
if not isinstance(v, str):
v = obj.get("text")
return v if isinstance(v, str) else ""
def is_main_thread(obj: dict) -> bool:
"""Hauptthread = kein Projekt-Tag. Brain nutzt 'project_id', UI/Bridge
'projectId'."""
pid = obj.get("project_id")
if pid is None:
pid = obj.get("projectId")
return not (str(pid or "").strip())
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
apply = "--apply" in sys.argv[1:]
path = Path(args[0]) if args else Path("/data/conversation.jsonl")
if not path.exists():
print(f"FEHLER: {path} existiert nicht.", file=sys.stderr)
return 2
raw_lines = path.read_text(encoding="utf-8").splitlines()
# Parse zu (raw, obj|None). Nicht-JSON / leere Zeilen bleiben unangetastet.
parsed: list[tuple[str, dict | None]] = []
for line in raw_lines:
s = line.strip()
if not s:
parsed.append((line, None))
continue
try:
parsed.append((line, json.loads(s)))
except Exception:
parsed.append((line, None))
drop = [False] * len(parsed)
poisoned_pairs = [] # (assistant_idx, user_idx|None) fuer's Log
for i, (_, obj) in enumerate(parsed):
if not isinstance(obj, dict):
continue
if obj.get("op") == "distill":
continue
if obj.get("role") != "assistant" or not is_main_thread(obj):
continue
content = get_content(obj)
if not content or not is_poison(content):
continue
# Gift-Assistant-Turn -> droppen
drop[i] = True
user_idx = None
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
for j in range(i - 1, -1, -1):
pj = parsed[j][1]
if not isinstance(pj, dict) or pj.get("op") == "distill":
continue
if pj.get("role") == "user" and is_main_thread(pj):
drop[j] = True
user_idx = j
break # nur der direkt vorangehende Turn
poisoned_pairs.append((i, user_idx))
n_drop = sum(drop)
if n_drop == 0:
print("Keine Gift-Turns gefunden — History ist sauber. Nichts zu tun.")
return 0
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
for a_idx, u_idx in poisoned_pairs:
if u_idx is not None:
uq = get_content(parsed[u_idx][1] or {})
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
ac = get_content(parsed[a_idx][1] or {})
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
print()
if not apply:
print("DRY-RUN — nichts geschrieben. Zum Anwenden erneut mit --apply aufrufen.")
return 0
backup = path.with_suffix(path.suffix + ".pre-cleanup.bak")
shutil.copy2(path, backup)
kept = [raw for idx, (raw, _) in enumerate(parsed) if not drop[idx]]
path.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
print(f"OK — {n_drop} Zeilen entfernt. Backup: {backup}")
print("Jetzt Brain neu starten: docker compose restart aria-brain")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+207
View File
@@ -0,0 +1,207 @@
"""
Conversation-State — ein einziger Rolling-Window-State fuer ARIAs
laufendes Gespraech mit Stefan.
Stefan-Entscheidung: KEINE Sessions, KEIN Multi-Thread. EIN Strang,
intern rollend. Was rausfaellt, wird ggf. destilliert und landet
als type=fact Memory in der Vector-DB.
Persistenz: append-only JSONL unter /data/conversation.jsonl.
Bei Restart wird die letzte N gelesen (komplett vermeidet Memory-
Overhead bei sehr langen Verlaeufen).
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
CONVERSATION_FILE = Path(os.environ.get("CONVERSATION_FILE", "/data/conversation.jsonl"))
@dataclass
class Turn:
role: str # "user" | "assistant"
content: str
ts: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
source: str = "" # "app" / "diagnostic" / "stt" — optional
project_id: str = "" # leer = Hauptthread; sonst projects.py-ID
class Conversation:
"""In-Memory Rolling Window, mit JSONL-Persistenz."""
def __init__(self, max_window: int = 50, distill_threshold: int = 60,
distill_count: int = 30):
self.max_window = max_window
self.distill_threshold = distill_threshold
self.distill_count = distill_count
self.turns: List[Turn] = []
self._load()
def _load(self):
if not CONVERSATION_FILE.exists():
return
try:
lines = CONVERSATION_FILE.read_text(encoding="utf-8").splitlines()
except Exception as exc:
logger.warning("Konversation laden fehlgeschlagen: %s", exc)
return
loaded: List[Turn] = []
for line in lines:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
if obj.get("op") == "distill":
# Marker: bis hierhin wurde alles destilliert
drop_until_ts = obj.get("ts", "")
if drop_until_ts:
loaded = [t for t in loaded if t.ts > drop_until_ts]
continue
role = obj.get("role")
content = obj.get("content")
if role in ("user", "assistant") and isinstance(content, str):
loaded.append(Turn(role=role, content=content,
ts=obj.get("ts", ""),
source=obj.get("source", ""),
project_id=obj.get("project_id", "")))
self.turns = loaded
logger.info("Konversation geladen: %d Turns aus %s", len(self.turns), CONVERSATION_FILE)
def _append_to_file(self, record: dict):
try:
CONVERSATION_FILE.parent.mkdir(parents=True, exist_ok=True)
with CONVERSATION_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as exc:
logger.warning("Konversation persist fehlgeschlagen: %s", exc)
def add(self, role: str, content: str, source: str = "",
project_id: str = "") -> Turn:
t = Turn(role=role, content=content, source=source, project_id=project_id)
self.turns.append(t)
record = {
"ts": t.ts, "role": t.role, "content": t.content, "source": t.source,
}
if t.project_id:
record["project_id"] = t.project_id
self._append_to_file(record)
return t
def window(self, project_id: Optional[str] = None) -> List[Turn]:
"""Die letzten max_window Turns — gehen in den LLM-Prompt.
Wenn project_id gesetzt: nur Turns aus diesem Projekt + die letzten
~5 Hauptthread-Turns als Kontext. Wenn project_id leer/None und
explizit uebergeben → nur Hauptthread."""
if project_id is None:
return self.turns[-self.max_window:]
if project_id == "":
# Hauptthread-Modus: alle Turns, aber project-getaggte rausfiltern
main_turns = [t for t in self.turns if not t.project_id]
return main_turns[-self.max_window:]
# In-Projekt: alle Turns des Projekts + Tail des Hauptthreads als Kontext
project_turns = [t for t in self.turns if t.project_id == project_id]
return project_turns[-self.max_window:]
def window_recent_per_project(self) -> dict:
"""Returns {project_id: [last N turns]} — fuer „hol mich ab"-Summary."""
groups: dict[str, List[Turn]] = {}
for t in self.turns:
pid = t.project_id or ""
groups.setdefault(pid, []).append(t)
return groups
def needs_distill(self) -> bool:
return len(self.turns) > self.distill_threshold
def take_oldest_for_distill(self) -> List[Turn]:
"""Gibt die N aeltesten Turns zurueck — fuer den Destillat-Call.
Entfernt sie NICHT — das macht commit_distill nach erfolgreichem Call."""
return self.turns[: self.distill_count]
def commit_distill(self, last_distilled_ts: str):
"""Schreibt einen Distill-Marker, entfernt aus dem In-Memory-Window."""
self._append_to_file({"op": "distill", "ts": last_distilled_ts})
self.turns = [t for t in self.turns if t.ts > last_distilled_ts]
logger.info("Distill commit bei ts=%s — Window jetzt %d Turns", last_distilled_ts, len(self.turns))
def reset(self):
"""Hardes Reset — verwende vorsichtig (Diagnostic-Button)."""
try:
if CONVERSATION_FILE.exists():
CONVERSATION_FILE.unlink()
except Exception:
pass
self.turns = []
logger.warning("Konversation komplett zurueckgesetzt")
def _rewrite_file(self) -> None:
"""Datei komplett aus In-Memory-State neu schreiben.
Wird nach Mutationen (Loeschen) genutzt. Alte distill-Marker
gehen dabei verloren — das ist OK weil der In-Memory-State
bereits post-distill ist."""
try:
CONVERSATION_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = CONVERSATION_FILE.with_suffix(".jsonl.tmp")
with tmp.open("w", encoding="utf-8") as f:
for t in self.turns:
rec = {
"ts": t.ts, "role": t.role,
"content": t.content, "source": t.source,
}
if t.project_id:
rec["project_id"] = t.project_id
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
tmp.replace(CONVERSATION_FILE)
except Exception as exc:
logger.warning("Konversation rewrite fehlgeschlagen: %s", exc)
def remove_by_match(self, role: str, content: str,
ts_iso_hint: Optional[str] = None) -> bool:
"""Entfernt EINEN Turn mit passendem role + content.
Bei Mehrfach-Match (z.B. zwei identische 'ja'-Turns) waehlt
den naehesten zum ts_iso_hint, sonst den juengsten.
Returns True wenn was entfernt wurde.
"""
candidates = [(i, t) for i, t in enumerate(self.turns)
if t.role == role and t.content == content]
if not candidates:
logger.info("[conv] remove_by_match: kein Match fuer role=%s content[:40]=%r",
role, content[:40])
return False
if len(candidates) > 1 and ts_iso_hint:
def _diff(item):
_, turn = item
try:
return abs((datetime.fromisoformat(turn.ts.replace("Z", "+00:00"))
- datetime.fromisoformat(ts_iso_hint.replace("Z", "+00:00"))).total_seconds())
except Exception:
return 1e9
candidates.sort(key=_diff)
idx, turn = candidates[0] if not ts_iso_hint else candidates[0]
self.turns.pop(idx)
self._rewrite_file()
logger.info("[conv] Turn entfernt: role=%s ts=%s content[:40]=%r",
turn.role, turn.ts, turn.content[:40])
return True
def stats(self) -> dict:
return {
"turns": len(self.turns),
"max_window": self.max_window,
"distill_threshold": self.distill_threshold,
"needs_distill": self.needs_distill(),
}
+68
View File
@@ -0,0 +1,68 @@
"""
Local-LLM-Client (Plan B) — Brain-Seite.
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge:
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
das Brain bleibt HTTP-only). Der Router im Brain (B1) entscheidet, welche Turns
hierher gehen (einfach) und welche an Claude (schwer / Tool-Bedarf).
Rueckgabe von local_llm_chat: {ok, content, model?, elapsedMs?} oder {ok:False, error}.
Nie werfen — der Aufrufer entscheidet bei ok=False, ob er auf Claude eskaliert.
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Etwas ueber dem Bridge-seitigen _LLM_TIMEOUT_S (30s), damit der HTTP-Call nicht
# vor dem eigentlichen LLM-Timeout abbricht.
LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC", "35"))
def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
Blockierend (urllib) — chat() laeuft ohnehin im Executor-Thread."""
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if stop:
req["stop"] = stop
if tools:
req["tools"] = tools
if model:
req["model"] = model
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
err_data = json.loads(exc.read().decode("utf-8", "ignore"))
err = err_data.get("error") or str(exc)
except Exception:
err = str(exc)
return {"ok": False, "error": f"local-llm: {err}"}
except Exception as exc:
logger.warning("local_llm_chat HTTP-Call fehlgeschlagen: %s", exc)
return {"ok": False, "error": f"local-llm nicht erreichbar ({exc})"}
if not isinstance(result, dict) or not result.get("ok"):
return {"ok": False, "error": (result or {}).get("error", "unbekannt")}
return result
+1621
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
from .embedder import Embedder
from .vector_store import VectorStore, MemoryPoint, MemoryType
__all__ = ["Embedder", "VectorStore", "MemoryPoint", "MemoryType"]
+42
View File
@@ -0,0 +1,42 @@
"""
Lokaler Embedder fuer Memory-Texte.
Nutzt sentence-transformers (paraphrase-multilingual-MiniLM-L12-v2):
- Deutsch + Englisch
- 384-dimensionale Vektoren
- Laeuft auf CPU, ~30ms pro kurzer Text
- Modell wird beim ersten Aufruf in /data/_models gecached
"""
from __future__ import annotations
import logging
from typing import List
logger = logging.getLogger(__name__)
MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"
VECTOR_DIM = 384
class Embedder:
def __init__(self, model_name: str = MODEL_NAME):
self.model_name = model_name
self._model = None
def _load(self):
if self._model is None:
logger.info("Lade Embedding-Modell %s ...", self.model_name)
from sentence_transformers import SentenceTransformer
self._model = SentenceTransformer(self.model_name)
logger.info("Embedding-Modell geladen.")
def embed(self, text: str) -> List[float]:
self._load()
vec = self._model.encode(text, convert_to_numpy=True, normalize_embeddings=True)
return vec.tolist()
def embed_batch(self, texts: List[str]) -> List[List[float]]:
self._load()
vecs = self._model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
return vecs.tolist()
+275
View File
@@ -0,0 +1,275 @@
"""
Vector-Store-Wrapper um Qdrant.
Eine Collection "aria_memory" haelt ALLE Memory-Punkte.
Trennung nach Type/Pinned-Status via Payload-Filter.
Punkt-Schema (Payload):
type identity | rule | preference | tool | skill | fact | conversation | reminder
category frei, fuer UI-Gruppierung
title kurze Ueberschrift
content eigentlicher Text (wird embedded)
pinned bool, True = Hot Memory (immer in Prompt)
source import | conversation | manual
tags Liste von Strings
created_at, updated_at ISO-Strings
conversation_id optional, nur fuer type=conversation
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import List, Optional
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
from .embedder import VECTOR_DIM
logger = logging.getLogger(__name__)
COLLECTION = "aria_memory"
class MemoryType(str, Enum):
IDENTITY = "identity"
RULE = "rule"
PREFERENCE = "preference"
TOOL = "tool"
SKILL = "skill"
FACT = "fact"
CONVERSATION = "conversation"
REMINDER = "reminder"
@dataclass
class MemoryPoint:
id: str
type: str
title: str
content: str
pinned: bool = False
category: str = ""
source: str = "manual"
tags: List[str] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
conversation_id: Optional[str] = None
score: Optional[float] = None # nur bei Search gesetzt
# Anhaenge: Liste von Dicts {name, mime, size, path} — Dateien liegen
# physisch unter /shared/memory-attachments/<memory-id>/<name>.
# Hier in der DB nur die Metadaten, damit die Suche/Anzeige sie kennt
# ohne Filesystem zu pruefen.
attachments: List[dict] = field(default_factory=list)
def to_payload(self) -> dict:
p = {
"type": self.type,
"title": self.title,
"content": self.content,
"pinned": self.pinned,
"category": self.category,
"source": self.source,
"tags": self.tags,
"created_at": self.created_at,
"updated_at": self.updated_at,
"attachments": self.attachments,
}
if self.conversation_id:
p["conversation_id"] = self.conversation_id
return p
@classmethod
def from_qdrant(cls, point) -> "MemoryPoint":
payload = point.payload or {}
return cls(
id=str(point.id),
type=payload.get("type", "fact"),
title=payload.get("title", ""),
content=payload.get("content", ""),
pinned=payload.get("pinned", False),
category=payload.get("category", ""),
source=payload.get("source", "manual"),
tags=payload.get("tags", []),
created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""),
conversation_id=payload.get("conversation_id"),
attachments=payload.get("attachments", []) or [],
score=getattr(point, "score", None),
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
class VectorStore:
def __init__(self, host: str, port: int = 6333):
self.client = QdrantClient(host=host, port=port)
self._ensure_collection()
def _ensure_collection(self):
existing = [c.name for c in self.client.get_collections().collections]
if COLLECTION not in existing:
logger.info("Erstelle Collection %s ...", COLLECTION)
self.client.create_collection(
collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
)
# Indexe fuer typische Filter-Felder
for field_name in ("type", "pinned", "category", "source", "migration_key"):
self.client.create_payload_index(
collection_name=COLLECTION,
field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
else qm.PayloadSchemaType.BOOL,
)
# ─── Schreib-Operationen ─────────────────────────────────────────
def upsert(self, point: MemoryPoint, vector: List[float]) -> str:
if not point.id:
point.id = str(uuid.uuid4())
if not point.created_at:
point.created_at = _now()
point.updated_at = _now()
self.client.upsert(
collection_name=COLLECTION,
points=[qm.PointStruct(id=point.id, vector=vector, payload=point.to_payload())],
)
return point.id
def delete(self, point_id: str):
self.client.delete(
collection_name=COLLECTION,
points_selector=qm.PointIdsList(points=[point_id]),
)
# ─── Lese-Operationen ────────────────────────────────────────────
def get(self, point_id: str) -> Optional[MemoryPoint]:
result = self.client.retrieve(collection_name=COLLECTION, ids=[point_id], with_payload=True)
if not result:
return None
return MemoryPoint.from_qdrant(result[0])
def list_pinned(self) -> List[MemoryPoint]:
"""Alle pinned Punkte — Hot Memory."""
return self._scroll(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
]))
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll(
filter=qm.Filter(must=[
qm.FieldCondition(key="type", match=qm.MatchValue(value=type_))
]),
limit=limit,
)
def list_all(self, limit: int = 1000) -> List[MemoryPoint]:
return self._scroll(filter=None, limit=limit)
def _scroll(self, filter, limit: int = 1000) -> List[MemoryPoint]:
points, _ = self.client.scroll(
collection_name=COLLECTION,
scroll_filter=filter,
limit=limit,
with_payload=True,
with_vectors=False,
)
return [MemoryPoint.from_qdrant(p) for p in points]
def search(
self,
query_vector: List[float],
k: int = 5,
type_filter: Optional[str] = None,
exclude_pinned: bool = True,
score_threshold: Optional[float] = None,
) -> List[MemoryPoint]:
"""Semantische Search. Standard: pinned-Punkte ausgeschlossen
(die kommen separat via list_pinned in den Prompt).
score_threshold: nur Treffer mit Cosine-Similarity >= Schwelle
zurueckgeben. None = keine Filterung. MiniLM-multilingual liefert
typischerweise 0.3-0.6 fuer relevante Treffer; <0.25 ist Rauschen."""
must = []
must_not = []
if type_filter:
must.append(qm.FieldCondition(key="type", match=qm.MatchValue(value=type_filter)))
if exclude_pinned:
must_not.append(qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)))
flt = qm.Filter(must=must or None, must_not=must_not or None)
results = self.client.search(
collection_name=COLLECTION,
query_vector=query_vector,
query_filter=flt if (must or must_not) else None,
limit=k,
with_payload=True,
score_threshold=score_threshold,
)
return [MemoryPoint.from_qdrant(p) for p in results]
def count(self) -> int:
return self.client.count(collection_name=COLLECTION, exact=True).count
def search_text(
self,
query: str,
k: int = 20,
type_filter: Optional[str] = None,
exclude_pinned: bool = False,
) -> List[MemoryPoint]:
"""Volltext-Substring-Suche (case-insensitive) ueber Title +
Content + Category + Tags. Im Gegensatz zu search() ist das KEIN
Semantic-Match nur exakte Wort-/Teilwort-Treffer.
Full-Scan ueber alle (gefilteren) Punkte. Bei der erwarteten
Groessenordnung (< 1000) unkritisch."""
q = (query or "").strip().lower()
if not q:
return []
must = []
must_not = []
if type_filter:
must.append(qm.FieldCondition(key="type", match=qm.MatchValue(value=type_filter)))
if exclude_pinned:
must_not.append(qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)))
flt = qm.Filter(must=must or None, must_not=must_not or None) if (must or must_not) else None
matches: List[MemoryPoint] = []
offset = None
while True:
points, offset = self.client.scroll(
collection_name=COLLECTION,
scroll_filter=flt,
limit=200,
offset=offset,
with_payload=True,
with_vectors=False,
)
for p in points:
payload = p.payload or {}
tags = payload.get("tags")
tags_str = " ".join(tags) if isinstance(tags, list) else ""
haystack = " ".join([
str(payload.get("title", "")),
str(payload.get("content", "")),
str(payload.get("category", "")),
tags_str,
]).lower()
if q in haystack:
matches.append(MemoryPoint.from_qdrant(p))
if len(matches) >= k:
return matches
if not offset:
break
return matches
+172
View File
@@ -0,0 +1,172 @@
"""
Anhaenge fuer Memory-Eintraege.
Storage-Layout:
/shared/memory-attachments/<memory-id>/<original-name>
Eine flache Ordnerstruktur pro Memory bei Memory-Delete loescht main.py
das ganze Verzeichnis. Anhang-Metadaten (name, mime, size, path) liegen
zusaetzlich im Qdrant-Payload des Memory-Punkts damit die Listen/Suche
sie ohne Filesystem-Lookup zeigen kann.
Anhaenge sind erstmal nur ueber die Diagnostic-UI hochladbar ARIA
selbst hat in Stufe A kein Tool zum Upload.
"""
from __future__ import annotations
import base64
import logging
import mimetypes
import os
import re
import shutil
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
def _safe_filename(name: str) -> str:
"""Macht aus einem User-Namen einen filesystem-sicheren String —
zerlegt Pfadteile, schneidet Sonderzeichen weg, kuerzt auf 120 Zeichen."""
base = Path(name).name or "datei"
base = SAFE_NAME_RE.sub("_", base).strip("._-") or "datei"
return base[:120]
def memory_dir(memory_id: str) -> Path:
return ROOT / memory_id
def list_attachments(memory_id: str) -> List[dict]:
"""Liest die Anhaenge fuer eine Memory aus dem Filesystem.
Returns [{name, mime, size, path}, ...] leer wenn nichts da.
Source of Truth ist Qdrant-Payload; diese Funktion ist nur fuer
Diagnostic-Endpoints wenn Stefan direkt das FS prueft."""
d = memory_dir(memory_id)
if not d.is_dir():
return []
out = []
for f in sorted(d.iterdir()):
if not f.is_file():
continue
out.append(_file_meta(memory_id, f))
return out
def _file_meta(memory_id: str, f: Path) -> dict:
try:
size = f.stat().st_size
except Exception:
size = 0
mime = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
return {
"name": f.name,
"mime": mime,
"size": size,
"path": str(f), # absoluter Pfad im Container
}
def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
"""Schreibt einen Anhang ins FS und gibt seine Metadaten zurueck.
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
if not memory_id:
raise ValueError("memory_id ist Pflicht")
if len(data) > MAX_BYTES:
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
safe = _safe_filename(filename)
d = memory_dir(memory_id)
d.mkdir(parents=True, exist_ok=True)
target = d / safe
target.write_bytes(data)
logger.info("[mem-att] %s -> %s (%d Byte)", memory_id, safe, len(data))
return _file_meta(memory_id, target)
def save_from_base64(memory_id: str, filename: str, b64: str) -> dict:
"""Convenience fuer Base64-Uploads (Diagnostic schickt Files so)."""
try:
data = base64.b64decode(b64, validate=False)
except Exception as exc:
raise ValueError(f"Base64-Decode fehlgeschlagen: {exc}") from exc
return save_attachment(memory_id, filename, data)
def delete_attachment(memory_id: str, filename: str) -> bool:
"""Loescht eine einzelne Anhang-Datei. Returns True wenn was weg ist."""
safe = _safe_filename(filename)
target = memory_dir(memory_id) / safe
if not target.is_file():
return False
try:
target.unlink()
logger.info("[mem-att] %s/%s geloescht", memory_id, safe)
return True
except Exception as exc:
logger.warning("[mem-att] Loeschen fehlgeschlagen: %s", exc)
return False
def delete_all(memory_id: str) -> int:
"""Loescht das komplette Memory-Verzeichnis. Wird beim Memory-Delete
in main.py gerufen damit nichts verwaist."""
d = memory_dir(memory_id)
if not d.is_dir():
return 0
count = sum(1 for _ in d.iterdir() if _.is_file())
try:
shutil.rmtree(d)
logger.info("[mem-att] %s komplett entfernt (%d Files)", memory_id, count)
except Exception as exc:
logger.warning("[mem-att] rmtree fehlgeschlagen: %s", exc)
return count
def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
"""Liefert die rohen Bytes einer Datei zurueck — fuer Download/Serve."""
safe = _safe_filename(filename)
target = memory_dir(memory_id) / safe
if not target.is_file():
return None
return target.read_bytes()
# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path —
# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files
# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf
# /root, /etc, /tmp, ssh-Keys, etc.
ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/")
def attach_from_path(memory_id: str, source_path: str) -> dict:
"""Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis
des Memories und gibt die neue Metadaten zurueck.
Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_<id>.jpg`.
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
sie es via `memory_save(..., attach_paths=[<src>])` ins Memory-Verzeichnis.
Pfadschutz: source_path MUSS unter /shared/ liegen kein Zugriff auf
Root-FS, SSH-Keys etc.
"""
if not memory_id:
raise ValueError("memory_id ist Pflicht")
if not source_path or not isinstance(source_path, str):
raise ValueError("source_path leer")
if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen")
src = Path(source_path)
if not src.is_file():
raise ValueError(f"Datei nicht gefunden: {source_path}")
size = src.stat().st_size
if size > MAX_BYTES:
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
data = src.read_bytes()
return save_attachment(memory_id, src.name, data)

Some files were not shown because too many files have changed in this diff Show More