Compare commits

..
585 Commits
Author SHA1 Message Date
duffyduck a6d5b306b8 release: bump version to 0.1.8.6 2026-05-30 23:20:01 +02:00
duffyduck a26bf57eb1 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 27e87b95f6 release: bump version to 0.1.8.5 2026-05-30 23:11:16 +02:00
duffyduck 55994551c1 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 583141f349 release: bump version to 0.1.8.4 2026-05-30 23:02:41 +02:00
duffyduck 32a730aad1 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 437282f113 release: bump version to 0.1.8.3 2026-05-30 22:33:13 +02:00
duffyduck 2c7c5c3842 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 9d388c442e release: bump version to 0.1.8.2 2026-05-30 22:24:28 +02:00
duffyduck 51a7778c2f 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 080765d9b9 release: bump version to 0.1.8.1 2026-05-30 22:14:36 +02:00
duffyduck fd3a98cfec 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 058a2bf2e2 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 9f9c35cf60 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 6277f1940f fix(updater): parseInt(number) -> Number() — fileSize.size ist schon number 2026-05-30 21:45:17 +02:00
duffyduck 3714e806cd release: bump version to 0.1.8.0 2026-05-30 21:42:58 +02:00
duffyduck 0f3aa88c04 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 e442d4c24e 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 7461adc4cd 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 9488c8a789 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 9e014cf12b release: bump version to 0.1.7.0 2026-05-30 21:02:59 +02:00
duffyduck de63b66c0a 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 bd6ee91dfd release: bump version to 0.1.6.9 2026-05-30 20:46:55 +02:00
duffyduckandClaude Opus 4.7 89a9667bd8 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 2c5a75f364 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 5f299a3458 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 190c30ed03 release: bump version to 0.1.6.8 2026-05-30 20:29:41 +02:00
duffyduck 5384c57f6a release: bump version to 0.1.6.7 2026-05-30 20:28:38 +02:00
duffyduckandClaude Opus 4.7 ed8c3214cf 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://10.0.0.1: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 581b4f6e34 release: bump version to 0.1.6.6 2026-05-30 20:21:29 +02:00
duffyduckandClaude Opus 4.7 4df6b5a296 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 de87d3b99c release: bump version to 0.1.6.5 2026-05-30 20:12:39 +02:00
duffyduckandClaude Opus 4.7 788573102a 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 769f979aa7 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 d0096d8).
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 d0096d8002 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 8e3c09f4be release: bump version to 0.1.6.4 2026-05-30 19:31:08 +02:00
duffyduckandClaude Opus 4.7 6d3ea7b3c0 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 bbedbc38a4 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 4c9116db58 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 3fdd6b9) und Android-App (commit
dfd3ec6) 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 3358c541c5 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 55a5e2880c 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 d382912f69 feat(brain): Hard-Safety-Seed — keine destruktiven Tests auf Production
Beobachtung 30.05.2026 08:28-08:54: ARIA hat einen Pentest gegen
ein Kunden-Produktionssystem (Production!) angesetzt statt gegen
dessen Staging-Umgebung (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 7216ea0a52 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 c02da4e229 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 717943e.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 02:47:32 +02:00
duffyduckandClaude Opus 4.7 717943ebfa 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 5300fbdd5d 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 9651d41eb5 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 f12ebdddad 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 7cc18b762d 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 5f87addc7b 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 f00d65fab1 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 4a8bb8e442 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 0fc0206d7a 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 84486ceb38 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 5f83a60df0 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 ab685c657b 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 8739f04f6b 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 90f0d766a8 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 6f992b61f3 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 5e1c9afb3b 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 3799cae1fd 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 3e52825be6 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 2592d4fea0 release: bump version to 0.1.6.3 2026-05-28 23:58:26 +02:00
duffyduckandClaude Opus 4.7 2d75086e07 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 99cc38b356 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 ec22a3dbdd 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 ef5b8ae00e 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 916faa9ba5 changed docker compose rvs to 444 2026-05-25 10:31:27 +02:00
duffyduckandClaude Opus 4.7 e3b03e30a4 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 dfd3ec6e7d 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 9d5743ea34 release: bump version to 0.1.6.2 2026-05-25 10:00:47 +02:00
duffyduckandClaude Opus 4.7 dc591e3906 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 3fdd6b9d74 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 c795867d89 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 95a16b8de7 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 b1c89c62c7 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 6166d4f85c 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 00c54390bf 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 f7c572758b 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 cf32f2ebd0 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 3db6c70064 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 ac595a0dc8 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 18fba89048 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 0d25e403bc 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 3a4843e0ae 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 4d3db087fc release: bump version to 0.1.6.1 2026-05-16 23:29:54 +02:00
duffyduckandClaude Opus 4.7 37e43be6a8 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 8458b81bb4 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 787c0fd85a 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 57bebf0ce4 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 4d5a6d7a69 release: bump version to 0.1.6.0 2026-05-16 19:21:04 +02:00
duffyduckandClaude Opus 4.7 fe20989a62 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 4409c612b8 release: bump version to 0.1.5.9 2026-05-16 18:41:10 +02:00
duffyduck 2cdc6ab978 release: bump version to 0.1.5.8 2026-05-16 18:06:37 +02:00
duffyduck 79368f1e69 release: bump version to 0.1.5.7 2026-05-16 16:39:35 +02:00
duffyduckandClaude Opus 4.7 589dd6e0b9 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 456092b55f 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 a8d34af21c 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 38400f0c22 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 608f52741f release: bump version to 0.1.5.6 2026-05-16 14:32:34 +02:00
duffyduck 6dae004948 release: bump version to 0.1.5.5 2026-05-16 11:29:45 +02:00
duffyduckandClaude Opus 4.7 5faa049f53 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 94b419c995 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 3e8fd43381 release: bump version to 0.1.5.4 2026-05-15 22:51:27 +02:00
duffyduckandClaude Opus 4.7 19c5d18a57 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 8bdc2ee853 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 82693bba9f 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 57d53b56f0 release: bump version to 0.1.5.3 2026-05-15 22:33:10 +02:00
duffyduckandClaude Opus 4.7 764545c833 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 303653f686 release: bump version to 0.1.5.2 2026-05-15 21:48:14 +02:00
duffyduckandClaude Opus 4.7 e6bf071ae0 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 34d5b69f6a release: bump version to 0.1.5.1 2026-05-15 12:12:17 +02:00
duffyduckandClaude Opus 4.7 fa79d9415f 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 92295bbae1 release: bump version to 0.1.5.0 2026-05-15 12:03:39 +02:00
duffyduckandClaude Opus 4.7 dcd55b723f 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 8e91d5a48d 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 b1066f6a37 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 b758d11c30 release: bump version to 0.1.4.9 2026-05-15 11:45:53 +02:00
duffyduckandClaude Opus 4.7 b7ededb2e1 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 fb637aec49 release: bump version to 0.1.4.8 2026-05-15 11:28:06 +02:00
duffyduckandClaude Opus 4.7 d2ae380e1f 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 eb9f2d21ef release: bump version to 0.1.4.7 2026-05-15 11:11:33 +02:00
duffyduckandClaude Opus 4.7 1220c382e4 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 d950272b36 release: bump version to 0.1.4.6 2026-05-15 10:59:10 +02:00
duffyduckandClaude Opus 4.7 57bbda8e92 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 example.com: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 edd1d62283 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 ac2796ed79 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 3f6ce93ed3 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 41289be0c4 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 1c3b26db99 release: bump version to 0.1.4.5 2026-05-14 23:38:45 +02:00
duffyduckandClaude Opus 4.7 d72b00083d 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 5a569b5c2f release: bump version to 0.1.4.4 2026-05-14 23:09:08 +02:00
duffyduck c3c4d888ca release: bump version to 0.1.4.3 2026-05-14 22:59:25 +02:00
duffyduckandClaude Opus 4.7 fba5747b18 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 01d5b24ee4 release: bump version to 0.1.4.2 2026-05-14 22:29:31 +02:00
duffyduck 6ac9822ad8 release: bump version to 0.1.4.1 2026-05-14 22:16:17 +02:00
duffyduckandClaude Opus 4.7 f8decd22e2 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 a4e1fdd544 release: bump version to 0.1.4.0 2026-05-14 18:39:58 +02:00
duffyduckandClaude Opus 4.7 80bfa35d9d 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 aec7067352 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 e2cc0d9827 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 a96c033a4c 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 476c3a0a63 release: bump version to 0.1.3.9 2026-05-14 17:17:44 +02:00
duffyduckandClaude Opus 4.7 a6b45fd076 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 aa6ba1f3ac 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 b3aac1433e release: bump version to 0.1.3.7 2026-05-14 16:08:30 +02:00
duffyduckandClaude Opus 4.7 e700ff8d52 fix(app): URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt
Inbox-Crash gefunden via App-Crash-Reporter (commit 063b5b5):

  "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 063b5b5 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 6f4b3d2f12 release: bump version to 0.1.3.6 2026-05-14 16:00:42 +02:00
duffyduckandClaude Opus 4.7 e2edc37b9f 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 063b5b5 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 50363d53a5 release: bump version to 0.1.3.5 2026-05-14 15:45:36 +02:00
duffyduckandClaude Opus 4.7 063b5b5853 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 76565f354d release: bump version to 0.1.3.4 2026-05-14 14:58:47 +02:00
duffyduck 26978cc43f release: bump version to 0.1.3.3 2026-05-14 14:08:03 +02:00
duffyduckandClaude Opus 4.7 90a2721183 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 dc0ed185bc release: bump version to 0.1.3.2 2026-05-14 13:59:09 +02:00
duffyduckandClaude Opus 4.7 af7de386f1 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 bb7cb29429 release: bump version to 0.1.3.1 2026-05-14 13:31:59 +02:00
duffyduckandClaude Opus 4.7 f78823a64b 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 e0131257dc 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 04a1bd3607 release: bump version to 0.1.3.0 2026-05-14 13:18:55 +02:00
duffyduckandClaude Opus 4.7 658bf42dff 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 2e581d1fb0 release: bump version to 0.1.2.9 2026-05-13 08:55:28 +02:00
duffyduckandClaude Opus 4.7 856a5adb4e 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 f22663ca25 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 989d6053e5 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 dc35ac2f4e 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 29f425589c 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 7021df7c33 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 66d26ad641 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 a587170e8a 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 9746b3b472 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 eaebfd038e 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 82d11c7a89 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 4a598780b1 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 dd181f36cd 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 139bc318b2 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 83c7a3f00f 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 c5b29f2f46 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 58f199e452 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 7cd87c974b 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 ebdb306c03 release: bump version to 0.1.2.8 2026-05-12 16:45:29 +02:00
duffyduckandClaude Opus 4.7 f9b55012f2 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 fee0f061b8 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 7ead1212e6 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 282db900d4 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 f4008f44cb 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 baac815df1 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 fdb7edfcbd 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 b0f3a7b667 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 92da39ba17 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 0b222fbe46 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 a9f0f7be83 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 56b7b0868a 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 14026ee940 release: bump version to 0.1.2.7 2026-05-12 01:23:18 +02:00
duffyduckandClaude Opus 4.7 8fc65a986d 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 e4fc3fa976 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 45f0b44db2 release: bump version to 0.1.2.6 2026-05-12 01:08:35 +02:00
duffyduckandClaude Opus 4.7 6b91dd94a3 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 b3f1a32c8a 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 27a20ff73f 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 cd8700101b release: bump version to 0.1.2.5 2026-05-12 00:40:25 +02:00
duffyduckandClaude Opus 4.7 f60db2f156 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 c8ed864a31 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 71885bd22f 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 150ca9055f release: bump version to 0.1.2.4 2026-05-11 23:57:16 +02:00
duffyduckandClaude Opus 4.7 a43b5dea48 docs: README + issue.md — App-Chat-Sync-Verhalten praezisiert
App-Chat-Sync ist seit Commit 039b401 "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 039b401e6f 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 5181f53052 release: bump version to 0.1.2.3 2026-05-11 23:48:00 +02:00
duffyduckandClaude Opus 4.7 3ddd888b72 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 18730763d8 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 93a193a572 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 b268559884 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 583e1b5e44 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 ad64af2e17 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 0f52f8c74e 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 c6761f9830 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 89185e3d4d 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 638c142513 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 1f42b48d7f release: bump version to 0.1.2.2 2026-05-11 22:38:45 +02:00
duffyduckandClaude Opus 4.7 808174fafd 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 ecc2bfd4a7 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 4fe6eb1b1e 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 0a3d43d531 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 4c94fb1eb4 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 1a819c62da 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 bebd437abc 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 5dae0a401c 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 dfe0b55e3e 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 ec8e4563a5 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 7d4fe293b5 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 3a6bce72c6 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 685c5ffb55 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 ed99775d69 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 b57abc67ba release: bump version to 0.1.2.1 2026-05-11 19:28:32 +02:00
duffyduck 20458f792b release: bump version to 0.1.2.0 2026-05-11 19:27:33 +02:00
duffyduckandClaude Opus 4.7 e7189a992b 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 76cac4b4e9 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 d7612fb8f0 release: bump version to 0.1.1.9 2026-05-11 19:17:30 +02:00
duffyduckandClaude Opus 4.7 0769e7bbf1 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 31d05a9a65 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 337af351e6 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 56d797ccb1 release: bump version to 0.1.1.8 2026-05-11 19:04:25 +02:00
duffyduckandClaude Opus 4.7 e4c8d7189d 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 202158c839 release: bump version to 0.1.1.7 2026-05-11 18:59:57 +02:00
duffyduckandClaude Opus 4.7 6f7c6643eb 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 5921008510 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 c1f800a46d release: bump version to 0.1.1.6 2026-05-11 02:25:38 +02:00
duffyduckandClaude Opus 4.7 125168e0ed 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 099999f10d 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 4363372e25 release: bump version to 0.1.1.5 2026-05-11 01:59:58 +02:00
duffyduckandClaude Opus 4.7 168590580c 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 f178322235 release: bump version to 0.1.1.4 2026-05-11 01:48:33 +02:00
duffyduckandClaude Opus 4.7 d73c800209 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 6aaf4243fc release: bump version to 0.1.1.3 2026-05-10 18:47:10 +02:00
duffyduckandClaude Opus 4.7 4e5fca6567 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 f6499d9514 release: bump version to 0.1.1.2 2026-05-10 18:44:05 +02:00
duffyduckandClaude Opus 4.7 abcaea8259 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 a2a9ed06df 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 d7cecf2ea0 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 11fe69d182 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 3b42e59715 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 ae087bfd33 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 7f42e71b8f 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 96c3e0030a 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 66b222227b 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 cbce4dd798 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 a9d8c611b8 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 9377c12476 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 29ffacb697 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 5ace4ff128 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 24fd1b5960 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 2c34d44fb9 release: bump version to 0.1.0.9 2026-05-10 17:25:48 +02:00
duffyduckandClaude Opus 4.7 10b75edd9a 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 43f3a6b098 release: bump version to 0.1.0.8 2026-05-10 17:21:46 +02:00
duffyduckandClaude Opus 4.7 75881255d9 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 5f4c8184de release: bump version to 0.1.0.7 2026-05-10 17:14:11 +02:00
duffyduckandClaude Opus 4.7 46bf37a05e 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 d3bf0654f0 release: bump version to 0.1.0.6 2026-05-10 17:07:53 +02:00
duffyduckandClaude Opus 4.7 8ae3d83082 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 3fa4439441 release: bump version to 0.1.0.5 2026-05-10 17:02:56 +02:00
duffyduckandClaude Opus 4.7 db72674a50 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 d425e1a759 release: bump version to 0.1.0.4 2026-05-10 16:59:15 +02:00
duffyduckandClaude Opus 4.7 dbd7ffd534 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 98f4327784 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 abc8a666b6 release: bump version to 0.1.0.3 2026-05-10 16:43:37 +02:00
duffyduckandClaude Opus 4.7 990e7bb0e4 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 8c4fa7a49f release: bump version to 0.1.0.2 2026-05-10 16:38:00 +02:00
duffyduckandClaude Opus 4.7 b2107591aa 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 7b3a4e8655 release: bump version to 0.1.0.1 2026-05-10 16:30:14 +02:00
duffyduckandClaude Opus 4.7 b6a82d7701 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 ec5843c54f release: bump version to 0.1.0.0 2026-05-10 16:22:08 +02:00
duffyduckandClaude Opus 4.7 f5435d9367 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 1c28d88a35 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 a22d8e587f 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 35a9d12497 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 437959bd69 release: bump version to 0.0.9.9 2026-05-10 15:56:53 +02:00
duffyduckandClaude Opus 4.7 401617ffff 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 f93b769048 release: bump version to 0.0.9.8 2026-05-10 15:46:36 +02:00
duffyduckandClaude Opus 4.7 f48c7a05a9 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 6216c58bc0 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 056e87241d release: bump version to 0.0.9.7 2026-05-10 15:37:45 +02:00
duffyduckandClaude Opus 4.7 fd8145054b 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 f5b78b85bb release: bump version to 0.0.9.6 2026-05-10 15:29:03 +02:00
duffyduckandClaude Opus 4.7 48abcda873 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 addc6d3069 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 a9adb6562c 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 15beee0fa6 release: bump version to 0.0.9.5 2026-05-10 15:13:38 +02:00
duffyduckandClaude Opus 4.7 835eea0706 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 4ae8063797 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 a9f1134b0b 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 902362ca24 release: bump version to 0.0.9.4 2026-05-10 14:54:45 +02:00
duffyduckandClaude Opus 4.7 cc90ff4131 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 8c27090ef1 release: bump version to 0.0.9.3 2026-05-10 14:48:35 +02:00
duffyduckandClaude Opus 4.7 4afae31e56 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 faf9c0098b 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 e944d35263 release: bump version to 0.0.9.2 2026-05-10 14:40:35 +02:00
duffyduckandClaude Opus 4.7 03faf9ce05 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 7187c2bf3f 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 e717c0f98e release: bump version to 0.0.9.1 2026-05-10 14:32:35 +02:00
duffyduckandClaude Opus 4.7 1f2aeb8d88 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 15106ca011 release: bump version to 0.0.9.0 2026-05-10 14:26:41 +02:00
duffyduckandClaude Opus 4.7 55ce0372ac 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 a2aff77377 release: bump version to 0.0.8.9 2026-05-10 14:20:43 +02:00
duffyduckandClaude Opus 4.7 db250ce8ba 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 77b48a70b2 release: bump version to 0.0.8.8 2026-05-10 14:12:59 +02:00
duffyduckandClaude Opus 4.7 278812c8d7 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 e1ca01f507 release: bump version to 0.0.8.7 2026-05-10 14:00:29 +02:00
duffyduckandClaude Opus 4.7 af5b203b17 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 27a43ad0e8 release: bump version to 0.0.8.6 2026-05-10 12:50:35 +02:00
duffyduckandClaude Opus 4.7 0ade37d3be 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 122b4d05df 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 a342aa19a1 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 0f72cea2b5 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 e4781fd353 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 67d0cf00a4 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 78b134933c release: bump version to 0.0.8.5 2026-05-10 12:01:46 +02:00
duffyduckandClaude Opus 4.7 17a6c46fd6 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 ed176f1276 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 0817d775a3 release: bump version to 0.0.8.4 2026-05-10 11:53:48 +02:00
duffyduckandClaude Opus 4.7 38390c7a6a 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 d62f3e6d91 release: bump version to 0.0.8.3 2026-05-10 11:46:26 +02:00
duffyduckandClaude Opus 4.7 999b897e05 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 1ed97d2f91 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.0.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 732cfda799 release: bump version to 0.0.8.2 2026-05-07 08:31:06 +02:00
duffyduckandClaude Opus 4.7 47c44e57f3 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 63c61ba3f6 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 455eae2cfa release: bump version to 0.0.8.1 2026-05-06 23:49:36 +02:00
duffyduckandClaude Opus 4.7 bb0e48854a 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 1a4ef9e4ca 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 28206476be 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 0879a0f4fd 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 afb713364a 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 6426d3866a release: bump version to 0.0.8.0 2026-05-06 23:06:22 +02:00
duffyduckandClaude Opus 4.7 fb6bf18d76 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 ca055ddc6a release: bump version to 0.0.7.9 2026-05-06 23:00:32 +02:00
duffyduckandClaude Opus 4.7 200ee20d60 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 0f9a951964 release: bump version to 0.0.7.8 2026-05-06 22:58:20 +02:00
duffyduckandClaude Opus 4.7 dff6b958c0 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 ee89277e0d release: bump version to 0.0.7.7 2026-05-06 22:52:23 +02:00
duffyduckandClaude Opus 4.7 92550aca07 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 2220d0efe7 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 f80a17bbcd release: bump version to 0.0.7.6 2026-05-06 22:41:55 +02:00
duffyduckandClaude Opus 4.7 435b28ed78 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 b065e3c370 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 5e66c65a3f release: bump version to 0.0.7.5 2026-05-06 22:15:49 +02:00
duffyduckandClaude Opus 4.7 9c19ef12f1 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 f3212c920d 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 8c9b30c8d5 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 bb7de8e102 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 2efdc2c6a9 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 ab0b224370 release: bump version to 0.0.7.4 2026-05-06 20:30:37 +02:00
duffyduckandClaude Opus 4.7 864de803de 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 e71a1b8374 release: bump version to 0.0.7.3 2026-05-06 20:12:01 +02:00
duffyduck 7c3330287c release: bump version to 0.0.7.2 2026-05-05 14:51:18 +02:00
duffyduckandClaude Opus 4.7 7c5b963372 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 d15c29ad14 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 41d8faa045 release: bump version to 0.0.7.1 2026-05-03 22:11:08 +02:00
duffyduck fceae95a3c release: bump version to 0.0.7.0 2026-05-03 21:59:38 +02:00
duffyduckandClaude Opus 4.7 bd6f1b24e3 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 f8dee3d6e2 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 b0774c697e release: bump version to 0.0.6.9 2026-05-01 00:08:08 +02:00
duffyduckandClaude Opus 4.7 862c677603 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 759541d248 release: bump version to 0.0.6.8 2026-04-26 13:26:00 +02:00
duffyduckandClaude Opus 4.7 e8963326b0 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 4bd8fde71c 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 7eb873aa4e release: bump version to 0.0.6.7 2026-04-26 13:08:13 +02:00
duffyduckandClaude Opus 4.7 b30f0f613d 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 40a8e14b52 release: bump version to 0.0.6.6 2026-04-26 12:59:26 +02:00
duffyduckandClaude Opus 4.7 8553442615 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 41909f867a release: bump version to 0.0.6.5 2026-04-25 22:51:00 +02:00
duffyduckandClaude Opus 4.7 770fdf72fa 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 26bc617bb6 release: bump version to 0.0.6.4 2026-04-25 20:58:10 +02:00
duffyduckandClaude Opus 4.7 d8d3eaa3ba 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 25eb6f5659 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 1f16bac3b8 release: bump version to 0.0.6.3 2026-04-25 20:35:25 +02:00
duffyduckandClaude Opus 4.7 8c5dcfe751 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 e241bde92e 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 52b1414d35 release: bump version to 0.0.6.2 2026-04-25 20:20:25 +02:00
duffyduckandClaude Opus 4.7 94a5d6714a 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 1456f01429 release: bump version to 0.0.6.1 2026-04-25 01:24:31 +02:00
duffyduckandClaude Opus 4.7 75d4c90c84 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 94d086005d release: bump version to 0.0.6.0 2026-04-25 01:13:42 +02:00
duffyduckandClaude Opus 4.7 cb15739648 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 c89ef00759 release: bump version to 0.0.5.9 2026-04-25 01:04:00 +02:00
duffyduckandClaude Opus 4.7 3990aeaf74 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 6e0a4fd022 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 c9068bf7ba release: bump version to 0.0.5.8 2026-04-25 00:51:13 +02:00
duffyduckandClaude Opus 4.7 7db944312e 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 e304b156fb 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 4dcc952911 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 1164c43eec 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 0324643257 release: bump version to 0.0.5.7 2026-04-25 00:31:12 +02:00
duffyduckandClaude Opus 4.7 9f8039a91c 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 adc5ed5348 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 00f5c30354 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 a3ab1965f8 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 fba042fe6d 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 3ca599b3ce 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 d209c4f2f4 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 37beed7b0b 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 ea129efe25 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 134b321895 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 c1e4fb6b13 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 7dd13f1139 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 338793812e 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 85c8d38ea3 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 b35933b2b9 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 6f4c35cae9 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 62a1f9320b 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 43664edbb7 release: bump version to 0.0.5.6 2026-04-24 16:25:53 +02:00
duffyduckandClaude Opus 4.7 280cc29124 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 a4746bfd07 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 f638450c68 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 1c647ab2a3 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 299959bfae 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 f06b311c10 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 962a977375 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 9bfab12b28 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 f94194edb4 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 f87655db34 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 d73ea078d9 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 e1502fe3f3 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 1d10f5d94e 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 174e77f3ed 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 f4eb153f58 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 76de8296b3 release: bump version to 0.0.5.4 2026-04-24 14:45:17 +02:00
duffyduckandClaude Opus 4.7 41cfd3c8a7 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 675927ad35 release: bump version to 0.0.5.3 2026-04-24 14:41:59 +02:00
duffyduckandClaude Opus 4.7 0bd335b918 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 d57e91caf8 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 7e715b7a28 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 be30d22296 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 19ce272873 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 4882ebec33 release: bump version to 0.0.5.2 2026-04-24 13:50:19 +02:00
duffyduckandClaude Opus 4.7 9b1769269d 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 c2163b9064 release: bump version to 0.0.5.1 2026-04-23 22:02:17 +02:00
duffyduck 5729753b2e release: bump version to 0.0.5.0 2026-04-23 15:31:18 +02:00
duffyduckandClaude Opus 4.7 ba3a4eebb2 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 351a7822e1 release: bump version to 0.0.4.9 2026-04-22 23:02:28 +02:00
duffyduckandClaude Opus 4.7 5e653969a6 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 3a17c5f08d 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 ad7ec11be2 release: bump version to 0.0.4.8 2026-04-22 19:08:00 +02:00
duffyduckandClaude Opus 4.7 a014a05a37 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 d7e2f3f630 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 0879a9713d 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 d3b6612782 release: bump version to 0.0.4.7 2026-04-22 18:46:25 +02:00
duffyduckandClaude Opus 4.7 b26fe2e80c 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 10e98338ed release: bump version to 0.0.4.6 2026-04-22 18:32:31 +02:00
duffyduckandClaude Opus 4.7 00338820c5 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 6582eb0f90 release: bump version to 0.0.4.5 2026-04-22 18:18:20 +02:00
duffyduckandClaude Opus 4.7 80242de9bd 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 41ac3ab49a 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 9e100f5a4d 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 e8994b84fd 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 1c04882edf 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 ac6de75c2b 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 a61ce9ea95 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 eb36bd43ce 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 c5f983b530 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 4e8df5efa1 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 022a691a51 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 8e27378e81 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 85a2ead28e release: bump version to 0.0.4.4 2026-04-20 16:42:19 +02:00
duffyduckandClaude Opus 4.7 2e7f5c175a 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 6486f197ac release: bump version to 0.0.4.3 2026-04-20 08:01:46 +02:00
duffyduckandClaude Opus 4.7 af915effb9 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 5768f7fef5 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 497d68688e 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 ec1b14614f 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 6a9bdd6ed2 release: bump version to 0.0.4.2 2026-04-19 23:01:55 +02:00
duffyduckandClaude Opus 4.7 1f690d23f9 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 04e77e5269 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 d147424e27 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 592922ce6e 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 f5db0870f1 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 8e3ebfcc28 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 17ce4e11e0 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 d598fa7faf 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 7958cc2629 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 d507022894 release: bump version to 0.0.4.1 2026-04-19 21:00:49 +02:00
duffyduckandClaude Opus 4.7 d7a775f4ae 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 f862fd65ed 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 2077b4f876 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 2a94e9320e 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 aed38c2c97 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 7726544ead 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 bd88d0cb0d 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 42d37084d7 release: bump version to 0.0.4.0 2026-04-18 12:28:08 +02:00
duffyduckandClaude Opus 4.7 8e28a0c774 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 74f571d3a9 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 f33d016696 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 17e94b3348 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 6a6504073e release: bump version to 0.0.3.9 2026-04-18 11:52:53 +02:00
duffyduckandClaude Opus 4.7 57e1955c7b 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 c1ca2f1c62 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 745fb4f267 release: bump version to 0.0.3.8 2026-04-18 11:41:12 +02:00
duffyduckandClaude Opus 4.7 009aed0a18 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 f59c5f1170 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 f003c29b7a 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 ae5ef4ff83 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 e8dd14e1a8 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 5086091f52 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 ed4a0c3ea4 release: bump version to 0.0.3.7 2026-04-11 13:18:17 +02:00
duffyduckandClaude Opus 4.6 465a3d8741 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 6572637e8f 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 4bc02da982 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 db5e36f62c release: bump version to 0.0.3.6 2026-04-11 12:19:00 +02:00
duffyduckandClaude Opus 4.6 65c30e008a 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 51a97340fe release: bump version to 0.0.3.5 2026-04-11 12:13:41 +02:00
duffyduckandClaude Opus 4.6 81af73bc1d 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 f92896af95 release: bump version to 0.0.3.4 2026-04-11 12:09:23 +02:00
duffyduckandClaude Opus 4.6 7365845cdd 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
duffyduck e3d6b6c47e release: bump version to 0.0.3.3 2026-04-11 12:04:37 +02:00
duffyduckandClaude Opus 4.6 8d0322c0c8 fix: Remove duplicate closure ending in ChatScreen (build error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:03:20 +02:00
duffyduckandClaude Opus 4.6 9332520ef1 fix: Comprehensive markdown/formatting cleanup for TTS (Piper + XTTS)
- Remove **bold**, *italic*, `code`, code blocks, links, headers, quotes, lists
- Replace newlines with natural pauses (period/comma)
- Remove quotation marks, empty brackets
- Fixes text being swallowed/garbled by TTS engines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:47:04 +02:00
duffyduckandClaude Opus 4.6 797256cc5b docs: mark conversation mode as done, keep Porcupine as Phase 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:42:33 +02:00
duffyduckandClaude Opus 4.6 89dbb5b893 feat: Conversation mode (ear button) - auto-record after ARIA speaks
- Ear button activates conversation mode (green dot)
- After TTS playback finishes → 800ms pause → auto-start recording
- VAD stops recording on silence → sends to ARIA → ARIA answers → TTS → loop
- Like a natural conversation / walkie-talkie mode
- Audio service fires onPlaybackFinished when queue empty

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:40:55 +02:00
duffyduckandClaude Opus 4.6 3758cbc098 docs: mark scroll bugs as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:37:53 +02:00
duffyduckandClaude Opus 4.6 5380586fd4 fix: Auto-scroll to last message on app start + new messages
- useEffect on messages array instead of onContentSizeChange
- Instant jump (no animation) when loading history
- Animated scroll for single new messages
- Scroll pauses when user scrolls up, resumes at bottom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:37:30 +02:00
duffyduckandClaude Opus 4.6 c1f444257a feat: Multiple attachments + paste support (App + Diagnostic)
App:
- Multiple pending attachments (horizontal scroll preview)
- Individual remove (X) or clear all
- Send button shows when any attachment pending
- All files sent before text message

Diagnostic:
- Clip icon for file selection (multiple)
- Paste images/files from clipboard (Ctrl+V)
- Pending preview with thumbnails
- Files sent via RVS before text message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:34:33 +02:00
duffyduckandClaude Opus 4.6 acea6f84cb docs: mark attachment text feature as done in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:06:13 +02:00
duffyduckandClaude Opus 4.6 cece7c6920 feat: Attachments not sent immediately - add text/voice before sending
- File/photo selection stores as pending (not sent immediately)
- Preview bar shows pending attachment above input field
- User can add text message before sending (e.g. "Was siehst du?")
- Send button appears when attachment is pending (even without text)
- Placeholder changes to "Text zum Anhang (optional)..."
- X button to cancel pending attachment
- File + text sent together (file first, then chat message)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:05:50 +02:00
duffyduckandClaude Opus 4.6 7150519c31 docs: mark text message bug as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:59:48 +02:00
duffyduckandClaude Opus 4.6 b5c94a6d12 docs: mark auto-update bugs as fixed in issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:54:57 +02:00
duffyduck dde1d40de4 release: bump version to 0.0.3.2 2026-04-11 09:53:54 +02:00
duffyduckandClaude Opus 4.6 9b74919f8d fix: release.sh clears Metro + Gradle cache before build (version consistency)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:51:41 +02:00
duffyduck f03889c213 release: bump version to 0.0.3.1 2026-04-10 23:19:40 +02:00
duffyduckandClaude Opus 4.6 3b398c20d2 fix: Bridge chat handler was missing send_to_core (text messages ignored)
The chat handler checked sender but never forwarded the text to aria-core.
Only voice messages worked because they went through the audio→STT→send_to_core path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:13:29 +02:00
duffyduck f9d6e2354a release: bump version to 0.0.3.0 2026-04-10 09:07:20 +02:00
duffyduck 1bdaa0ee39 release: bump version to 0.0.2.9 2026-04-10 09:01:47 +02:00
duffyduckandClaude Opus 4.6 9d222e51f2 fix: release.sh deletes old APKs on RVS before uploading new one
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:00:45 +02:00
duffyduckandClaude Opus 4.6 c8e17ca5d3 fix: Auto-update APK install via FileProvider + dynamic version
- Native ApkInstallerModule: FileProvider content:// URI for Android 7+
- REQUEST_INSTALL_PACKAGES permission in AndroidManifest
- file_paths.xml for FileProvider cache access
- APP_VERSION reads from package.json (not hardcoded)
- "Auf Updates pruefen" button in Settings
- Version display reads from package.json dynamically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:59:52 +02:00
duffyduckandClaude Opus 4.6 f3ea0753cf docs: add auto-update FileProvider bug + update check button to issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:55:30 +02:00
duffyduck fcd3709229 release: bump version to 0.0.2.8 2026-04-10 08:49:47 +02:00
duffyduckandClaude Opus 4.6 7075f1cf4c docs: document .env.example with detailed comments, explain both tokens in README
- ARIA_AUTH_TOKEN: Gateway auth (who can talk to ARIA)
- RVS_TOKEN: Pairing token (same room in RVS relay)
- RVS_UPDATE_HOST: SSH target for auto-update APK copy
- All variables with German comments and examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:45:26 +02:00
duffyduckandClaude Opus 4.6 efdfa6ffcf docs: add auto-update APK not reaching RVS bug to issue.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:39:59 +02:00
duffyduckandClaude Opus 4.6 eb8dfbc54b fix: Proxy SSH volume read-write (ARIA can manage keys without -F workaround)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:34:35 +02:00
duffyduckandClaude Opus 4.6 0e2392ea06 docs: add 6 new bugs/features to issue.md
- Session persistence on container restart
- App: text/image/attachment messages not working (only voice)
- App: audio stops randomly
- App: auto-scroll to last message on start + new messages
- App: add text/voice to attachments
- Prioritized bugs section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:31:49 +02:00
duffyduckandClaude Opus 4.6 1b92af2ecc fix: XTTS chunk size 150 chars (faster render, preload overlaps playback)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:52:56 +02:00
duffyduckandClaude Opus 4.6 21e07ac3eb fix: XTTS streaming mode - send each chunk immediately, comma between sentences
- Back to streaming: render chunk → send immediately → next chunk
- App plays with preloading queue (no waiting for all chunks)
- Comma instead of dot between sentences in chunk (no "Punkt" read aloud)
- Sentence-ending dots already removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:48:50 +02:00
duffyduckandClaude Opus 4.6 cac9d28736 fix: XTTS splits concatenated audio into ~8s parts (seamless with preload)
- All chunks rendered and PCM concatenated (consistent voice)
- Split into ~8 second WAV parts (not per-sentence)
- 8s is long enough for preload overlap, small enough for WebSocket
- Parts include part/totalParts metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:41:14 +02:00
duffyduckandClaude Opus 4.6 a9ad19ed14 feat: XTTS concatenates chunks into seamless WAV (no stuttering)
- All chunks rendered sequentially, PCM data concatenated
- Single WAV with proper header sent back (no queue needed in app)
- If total > 800KB, split into parts (WebSocket limit)
- Eliminates stuttering between sentences

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:40:16 +02:00
duffyduckandClaude Opus 4.6 bc87f86e4a docs: update issue.md with XTTS streaming as next priority
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:38:21 +02:00
duffyduck e5ed2898d2 release: bump version to 0.0.2.7 2026-04-10 02:24:58 +02:00
duffyduckandClaude Opus 4.6 232053d4ec fix: XTTS groups sentences into ~250 char chunks for consistent voice quality
- 2-3 sentences per chunk (more context = stable voice/volume)
- Max 250 chars per chunk (keeps WebSocket packets manageable)
- Dots re-added between sentences within a chunk (natural pauses)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:23:29 +02:00
duffyduckandClaude Opus 4.6 a98842bd1f fix: Audio preloading to prevent stuttering, remove trailing dots for XTTS
- Preload next audio while current plays (eliminates gap between sentences)
- Remove trailing dots from sentences (XTTS reads them aloud)
- stopPlayback cleans up preloaded audio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:21:19 +02:00
duffyduck 42d0ce8c4b release: bump version to 0.0.2.6 2026-04-10 02:11:04 +02:00
duffyduckandClaude Opus 4.6 34994e532f fix: Audio queue for sequential TTS playback (no overlap/skip)
- Audio packets queued instead of stopping previous
- _playNext() plays sequentially, each sentence after the previous
- stopPlayback() clears queue
- Fixes overlapping/skipping with XTTS sentence-by-sentence rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:09:35 +02:00
duffyduckandClaude Opus 4.6 53e85853e2 fix: XTTS bridge splits text into sentences sequentially
- XTTS-Bridge does sentence splitting (not ARIA-Bridge)
- Sequential rendering: correct order guaranteed
- Each sentence sent as separate xtts_response
- Markdown removal before splitting
- App starts playback after first sentence (faster UX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:03:29 +02:00
duffyduckandClaude Opus 4.6 f820264a20 fix: XTTS splits long text into sentences before sending (WebSocket size limit)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:56:25 +02:00
duffyduckandClaude Opus 4.6 94e5e4b665 docs: update README with XTTS v2 setup details, voice cloning guide
- Architecture diagram for XTTS flow (Gaming-PC ↔ RVS ↔ ARIA-VM)
- Port 8020 (not 8000), token must match, model caching
- Voice cloning step-by-step guide
- TTS engine switching (Piper/XTTS) with fallback
- Known limitation: RVS zombie connections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:49:08 +02:00
duffyduckandClaude Opus 4.6 6a1661f781 fix: XTTS bridge shares /voices volume with XTTS server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:40:41 +02:00
duffyduckandClaude Opus 4.6 bf001cf304 fix: XTTS voice dropdown shows saved voice after page reload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:34:00 +02:00
duffyduckandClaude Opus 4.6 92f555a038 fix: XTTS voices list + upload use fresh RVS connection with response wait
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:24:55 +02:00
duffyduckandClaude Opus 4.6 e86db3aceb fix: voice upload uses send() via server, not client-side sendToRVS_raw
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:15:29 +02:00
duffyduckandClaude Opus 4.6 dee88aec98 fix: voice upload Base64 conversion (chunked, no stack overflow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:08:32 +02:00
duffyduckandClaude Opus 4.6 3db2ea9635 fix: Bridge _send_to_rvs ping-check before send, force reconnect on zombie
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:37:22 +02:00
duffyduckandClaude Opus 4.6 d2148353e6 fix: TTS toggle global above engine selector, health check /docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:27:55 +02:00
duffyduckandClaude Opus 4.6 8fa91dcd97 fix: XTTS model cache volume path /app/xtts_models
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:44:29 +02:00
duffyduckandClaude Opus 4.6 4d9ed81c10 fix: XTTS bridge port 8020, longer startup wait
- XTTS API runs on port 8020 (not 8000)
- Bridge waits up to 5min for model download (30x10s)
- Health check uses / instead of /docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:45 +02:00
duffyduck 3ace2dc22c updated image public path 2026-04-07 23:06:26 +02:00
duffyduck 37200803ce release: bump version to 0.0.2.5 2026-04-05 20:12:56 +02:00
duffyduck 2295516836 release: bump version to 0.0.2.4 2026-04-05 19:51:19 +02:00
duffyduckandClaude Opus 4.6 b5a31d3a4a docs: update README with XTTS, auto-update, watchdog, TTS settings
- Architecture: Added XTTS v2 (Gaming-PC) and auto-update flow
- Diagnostic: Thinking indicator, cancel button, TTS tab, voice cloning
- App: Play button, chat search, auto-update, voice speed settings
- RVS: Auto-update APK distribution over WebSocket
- Watchdog: 2min warning → 5min doctor --fix → 8min container restart
- Roadmap: Phase 1 fully completed, updated Phase 2+3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:46:16 +02:00
duffyduckandClaude Opus 4.6 404cb1d483 feat: XTTS v2 integration, auto-update system, TTS engine abstraction
- XTTS v2: Docker setup for Gaming-PC (GPU), bridge via RVS relay
- XTTS: Voice cloning UI in Diagnostic (multi-file upload)
- XTTS: Engine selectable (Piper local vs XTTS remote) with fallback
- Auto-Update: RVS serves APK over WebSocket (no HTTP needed)
- Auto-Update: App checks version on start, prompts install
- Auto-Update: release.sh copies APK to RVS via scp
- Bridge: TTS engine abstraction (piper/xtts), config persistent
- Bridge: xtts_response handler, tts_request on-demand
- Diagnostic: TTS engine dropdown, XTTS voice panel, voice cloning
- App: Play button on ARIA messages, chat search, update service
- Wake word: Disabled LiveAudioStream (crash fix), Phase 1 placeholder
- Watchdog: Container restart after 8min stuck
- Chat backup: on-the-fly to /shared/config/chat_backup.jsonl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:42:10 +02:00
67 changed files with 13188 additions and 671 deletions
+26 -1
View File
@@ -15,12 +15,22 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
# App, Bridge, Diagnostic und XTTS-Bridge verbinden sich hierueber.
# Alle muessen den gleichen Host, Port und Token nutzen.
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
# Hostname des RVS-Servers (z.B. rvs.example.de oder example.com)
# 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!).
+10
View File
@@ -25,6 +25,10 @@ aria-data/brain-import/*
!aria-data/brain-import/.gitkeep
!aria-data/brain-import/README.md
# .aria-debug/ — App-Crash-Logs die tools/fetch-app-logs.sh hier ablegt.
# Komplett lokal, enthaelt potentiell private Stacktraces / Daten.
.aria-debug/
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
aria-data/brain/data/
@@ -33,6 +37,12 @@ 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/
npm-debug.log*
+180 -11
View File
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), near(lat, lon, m) als Condition-Funktion
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), GPS-Funktionen `near() / entered_near() / left_near()` für unterschiedliche Geofencing-Modi
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
@@ -219,11 +219,15 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
Danach wird der Proxy gepatcht:
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
3. **CLI-Timeout** (sed): `DEFAULT_TIMEOUT 300000 → 1200000` (5 → 20 Min) im subprocess-manager. Multi-Tool-Workflows mit echtem Bash + curl + DB-Inserts brauchen oft 8–15 Min; 5 Min war chronisch zu kurz
4. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
- `routes.js` hookt die `assistant`-Events des Subprozesses und feuert pro `tool_use`-Block (Bash, Read, Edit, Grep, …) einen HTTP-POST an die Bridge (`/internal/agent-activity`). Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → der Gedanken-Stream zeigt live mit was ARIA gerade tut. Fire-and-forget, fail-open — Brain-Call bricht nicht ab wenn die Bridge mal nicht da ist.
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren. Der Tool-Hook im `routes.js` macht zusaetzlich das interne Claude-Code-Werkzeug-Geschehen fuer den User sichtbar.
**Brain ↔ Bridge ist async**: `_handle_rvs_message` ruft `send_to_core` als `asyncio.create_task` statt `await` — sonst blockierte der WS-recv-Loop bis zu 20 Min und der RVS-Server (example.com) droppte die Bridge nach ~4 Min Idle-Timeout. Brain laeuft jetzt im Hintergrund-Task, RVS-Verbindung bleibt waehrend ARIA arbeitet aktiv.
**Wichtige Umgebungsvariablen im Proxy:**
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
@@ -297,6 +301,16 @@ aria-brain → Antwort → Bridge → RVS → App
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
- **3-Schichten Hang-Schutz** (gegen tote NAT-Verbindungen + asyncio-Limbo):
(1) TCP-Keepalive auf dem RVS-Socket (30s idle / 10s probe / 3 retries —
tote Connections in ~1 min erkannt statt nach 2h Linux-Default),
(2) Asyncio-Heartbeat-Watchdog (eigene Coroutine, killt WS-Connection
wenn `_last_heartbeat_ok` > 60s stale ist — Schutz gegen
`ws.ping()`-Limbo bei halb-toten Verbindungen),
(3) File-Based Liveness Thread (separater OS-Thread, immun gegen asyncio-
Hangs, `os._exit(1)` nach 180s Staleness → Docker restart_policy
uebernimmt). Plus: TLS-Fallback klebt nicht mehr — bei Reconnect
wird wieder primary wss:// versucht.
### Betriebsmodi
@@ -310,18 +324,130 @@ aria-brain → Antwort → Bridge → RVS → App
---
## Skills — Architektur
Skills sind ARIAs wiederverwendbare Faehigkeiten. Jeder Skill ist ein
Python-Programm in seinem eigenen `local-venv`. ARIA legt sie selbst via
`skill_create` an, fixt Bugs mit `skill_update`, rollt zur Not zurueck
mit `skill_rollback`.
### Skill-Layout
```
/data/skills/<name>/
skill.json # Manifest (Metadata + config_schema + version_history)
run.py # Entry-Point (Python via venv-python)
requirements.txt # pip-Pakete fuer die venv
README.md # Beschreibung
venv/ # automatisch erzeugt
logs/<ts>.json # Run-Logs (append-only)
versions/v_<ts>/ # archivierte Vorgaengerstaende (vor jedem update_skill)
```
### Drei-Stufen-Daten-Modell
Skills muessen **niemals** Credentials hardcoden. Drei saubere Wege:
1. **OAuth2-Tokens** (Spotify, Google, GitHub, Reddit, …): Brain haelt
Client-Credentials und macht den Auth-Flow. Skill ruft
`GET {BRAIN_INTERNAL_URL}/oauth/<service>/token` und bekommt einen
frischen access_token (Auto-Refresh < 60 s Restzeit).
2. **Statische Werte** (API-Keys, User-IDs, Default-Geraete): Skill
deklariert ein `config_schema` in `skill.json`, Stefan setzt die
Werte in Diagnostic / App, Skill bekommt sie zur Laufzeit als
`CFG_<UPPER_NAME>` ENV.
3. **Brain-Daten** (Memories, Skills-Liste, Standort etc.): jeder Skill
kann gegen `BRAIN_INTERNAL_URL` Endpoints wie `/memory/search`,
`/memory/pinned`, `/skills/list` rufen — z.B. ein Wetter-Skill kann
Stefans Standort aus Memories holen statt ihn als Arg zu erwarten.
### Versionierung mit Rollback
`update_skill` archiviert den aktuellen Stand vor jeder strukturellen
Aenderung (entry_code, readme, pip_packages, config_schema, args) nach
`versions/v_<ts>/`. ARIA-Tools `skill_list_versions` + `skill_rollback`
(+ HTTP `/skills/{name}/versions` + `/rollback`) erlauben Wiederherstellung.
Vor jedem Rollback wird der aktuelle Stand als „safety-snapshot" gesichert
— der Rollback selbst ist also nicht destruktiv.
UI sowohl in Diagnostic (Skill-Detail → 📦 Versionen) als auch in der App
(SkillBrowser → Detail-Modal).
### Anti-Skill-Friedhof
ARIA hat frueher gerne 9 Spotify-Skills mit Suffixen `-v2`, `-aria`,
`-ctl`, `-fixed` gebaut statt einen sauberen zu pflegen.
`skills.create_skill()` rejected jetzt hart:
- Versions-Suffixe (`-v\d+`, `_v\d+`, `-new`, `-fixed`, `-old`,
`-alt`, `-copy`, `-final`, `-clean`)
- Prefix-Kollisionen (`spotify` existiert → `spotify-aria` rejected)
Plus die Skill-Regeln (siehe naechster Abschnitt) erinnern ARIA bei jedem
Chat-Turn an die richtigen Patterns.
### Skill-Regeln (seed_rules)
`aria-brain/seed_rules.py` enthaelt 20 `type=rule, pinned=true,
source=seed`-Memories, die bei jedem Brain-Start idempotent in die
Vector-DB geschrieben werden (`migration_key`-basiert). Sie tauchen in
jedem Chat-Turn im Hot-Memory-Block auf:
- **list-before-create** — IMMER `skill_list` vor `skill_create`
- **no-version-suffix** — keine `-v2`/`_v3`-Namen, Versionsverwaltung ist intern
- **update-not-recreate** — defekten Skill mit `skill_update` fixen, nicht neu bauen
- **no-hardcoded-credentials** — OAuth-Tokens via `oauth_get_token`, keine client_secrets im Code
- **config-schema-for-settings** — statische Werte via `config_schema`, nicht hardcoded
- **brain-internal-url** — `BRAIN_INTERNAL_URL` Endpoints inkl. `/oauth/<s>/token`, `/memory/search`, `/memory/pinned`, `/skills/list`
- **oauth-reauth-reflex** — bei 401: ZUERST `oauth_get_token` (Auto-Refresh), nur bei dessen Fehler `oauth_authorize`
- **no-skill-drift** — kein Drift vom Skill zu Ad-hoc-Bash-Befehlen. Skill kaputt? `skill_logs` + `skill_update`. Niemals nur SAGEN „ich baue dir einen Skill", wenn `skill_create` nicht wirklich gefeuert wird
- **runtime-topology** (architektur) — ARIA laeuft als `claude`-CLI-Subprocess IM aria-proxy Container (alpine — kein python3/jq), NICHT im aria-brain. `/data/skills/` und `BRAIN_INTERNAL_URL` existieren dort nicht. Brain-Resources via Brain-Tools (`oauth_get_token`, `memory_search`, `run_<skill>` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy)
- **scaffold-reflex** — ARIA entscheidet selbst ob ein wiederkehrender Bash-Pattern Skill-würdig ist (parametrisierbar + wiederkehrend + nicht-exploratory). Im Zweifel fragt sie Stefan. **Kein Auto-Scaffold, kein Tracking, keine Pflege** — Skills werden bewusst angelegt, nicht magisch. Pentest/Audit/Recherche bleibt ad-hoc Bash, auch bei 100× derselbe Host.
- **external-api-auth-strategy** — OAuth2 → `oauth_get_token`, sonst `config_schema`, NIEMALS hardcoden
### Skill-Scaffold (Templates)
Statt jedes Mal einen kompletten Skill aus dem Nichts zu generieren,
ruft ARIA `skill_scaffold(name, template, params)` — Brain expandiert
ein passendes Skelett. Massiv niedrigere Hürde gegen Skill-Drift.
Drei mitgelieferte Templates (`aria-brain/skill_templates.py`):
| Template | Wofür | params |
|---|---|---|
| `oauth-api` | Spotify, GitHub, Reddit, Google, Discord — Token aus Brain mit Auto-Refresh | `{service: "spotify", base_url?}` |
| `apikey-api` | OpenWeather, OpenAI, Twilio — statischer Key in `config_schema` → `CFG_<NAME>` ENV | `{api_name, key_env, auth_header?, auth_prefix?, base_url}` |
| `file-process` | PDF/Bild/JSON-Wandler — Input aus `/shared/uploads/`, Output zurueck. `process()`-Stub, danach `skill_update` mit echtem Code | `{output_ext}` |
HTTP: `POST /skills/scaffold` + `GET /skills/templates` (Liste mit Param-Doku).
Nach Scaffold optional `skill_update` falls Custom-Logik gebraucht wird.
Im Gegensatz zu `aria-data/brain-import/` (User-Saatgut, gitignored,
manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
Brain neu starten.
---
## Diagnostic — Selbstcheck-UI und Einstellungen
Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
- `entered_near(lat, lon, r)` — EINMAL beim Eintritt (Übergang außen→innen). Use-Case: Blitzer-Warner mit r=2000 → 2 km Vorwarnung, oder Ankunfts-Erinnerung mit r=100
- `left_near(lat, lon, r)` — EINMAL beim Verlassen (Übergang innen→außen). Use-Case: „Hast du am Parkplatz X was vergessen?"
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify Default, alle anderen Provider per ARIA on-demand oder "+ Custom"-Button mit auth_url/token_url/scopes) mit client_id+client_secret pro Service + One-Click-Autorisieren + Service-Loeschen, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
### Was zusaetzlich noch drin steckt
@@ -331,7 +457,11 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **SSH Terminal**: direkter SSH-Zugang zu aria-wohnung
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
- **Debug-API ohne SSH** (Diagnostic-Server, Port 3001):
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
---
@@ -355,15 +485,24 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
- **💭 Gedanken-Stream**: chronologisches Log was ARIA intern macht — gefuettert aus `agent_activity`-Events (denkt / 🔧 Tool-Name / schreibt / ✓ fertig). Live-Update waehrend Brain arbeitet: pro Tool-Call (Bash, Read, Edit, Grep, …) erscheint sofort ein Eintrag, durchgereicht vom claude-max-api-proxy via `proxy-patches/routes.js`-Hook. Lange Pausen zwischen Denk-Phasen werden als Trennlinie mit Minuten-Hint sichtbar. App: Icon in der Statusleiste oeffnet ein Bottom-Sheet, persistiert in AsyncStorage (capped 500). Diagnostic: identische Funktion als zentrales Modal im Chat-Test-Header
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
- **App-Crash-Reporting**: ungefangene JS-Errors + React-Render-Fehler landen automatisch in `/shared/logs/app.log` via RVS — kein ADB noetig, Logs holen via `tools/fetch-app-logs.sh` oder Diagnostic GET `/api/app-log`. ErrorBoundary verhindert White-Screen, zeigt stattdessen Error-Box im Modal mit Stack-Trace + Schliessen-Button
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
- **Bubble-Aktionen** (Long-Press oder ⎘-Icon): oeffnet ein Aktions-Menu mit "📋 Ganzen Text teilen" (System-Share-Sheet → Zwischenablage / WhatsApp / etc.) plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option. Plus native Text-Markierung via `selectable` ist weiter da
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
- **Settings-Sektionen "🛠️ Skills" und "🔑 OAuth-Apps"** (unterwegs konfigurieren ohne Diagnostic): Skills-Browser mit Run + Live-stdout/stderr + Logs der letzten 20 Runs + Loeschen; OAuth-Apps mit client_id/secret-Eingabe + "Autorisieren ↗" (oeffnet System-Browser, redirect zur RVS-Callback-Seite, Status-Refresh nach 8s) + "+ Custom"-Modal um eigene Provider mit auth_url/token_url/scopes anzulegen
- **Voice-Speed persistent**: App-Setting wird in `voice_config.json` als `xttsSpeed` persistiert. Greift jetzt auch bei Diagnostic-Chats / Trigger-Replies / nach Bridge-Restart — nicht mehr nur waehrend der App-Chat-Sitzung
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
- QR-Code Scanner fuer Token-Pairing
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-`![alt](url)`-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
@@ -580,16 +719,27 @@ tar -czf aria-backup-$(date +%Y%m%d).tar.gz aria-data/
## RVS — Rendezvous-Server
Laeuft im Rechenzentrum. WebSocket Relay + Auto-Update Server.
Laeuft im Rechenzentrum. WebSocket Relay + OAuth-Callback HTTP-Server.
Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
```bash
cd rvs
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
docker compose up -d
```
**Stack:**
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
**Features:**
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
- Heartbeat + tote Verbindungen aufraeumen
@@ -602,6 +752,11 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
---
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
@@ -867,15 +1022,29 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, drei GPS-Funktionen `near()` / `entered_near()` / `left_near()` für unterschiedliche Geofencing-Modi, Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Tick-Frequenz 8s + event-getriebene Auswertung bei jedem `location_update` (statt 30s-Polling) damit auch Auto-Vorbeifahrten bei 100+ km/h durch kleine Radien zuverlässig erwischt werden. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten. Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
- [x] **Memory-Editor in der App** (5 Etappen): Notizen-Inbox-Button neben der Lupe oeffnet ein Modal mit allen Spezial-Bubbles aus dem aktuellen Chat plus dem vollen DB-Browser. Tap auf eine Memory → Detail-Modal mit Anhang-Vorschau, Stift-Icon wechselt in Edit-Mode (Felder editieren + Anhaenge hoch-/runterladen + loeschen). Identischer Editor unter Settings → 🧠 Gedaechtnis. Bubble-Header dynamic je nach Aktion (created/updated/deleted). RVS-Brain-Proxy als Fundament (`brain_request`/`brain_response`) damit die App beliebige Brain-HTTP-Endpoints adressieren kann. `memory_search` + `memory_update` als ARIA-Tools damit sie aktiv die DB pruefen und Eintraege patchen kann statt zu fragmentieren.
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary + global JS-Error-Handler + Promise-Rejection-Tracker schicken Crashes als `app_log`-Event durch RVS. Bridge sammelt in `/shared/logs/app.log`, Diagnostic GET `/api/app-log`. `tools/fetch-app-logs.sh` holt die Logs auf die Dev-Maschine (gitignored `.aria-debug/`). Damit kann Stefan unterwegs ohne ADB debuggen — der erste Bug (URLSearchParams in Hermes) wurde so in 5 Minuten gefunden.
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
- [x] **Skill-Architektur P0-P4**:
- `seed_rules` (9 pinned rule-Memories) werden bei jedem Brain-Boot idempotent in die DB geschrieben (`source=seed`, `migration_key`-basiert). Decken Skill-Friedhof, OAuth-Auth-Strategie, no-skill-drift, BRAIN_INTERNAL_URL ab
- Anti-Friedhof-Check in `create_skill`: rejected Versions-Suffixe + Prefix-Kollisionen hart
- Neuer Brain-HTTP-Endpoint `/oauth/<service>/token` + `BRAIN_INTERNAL_URL` ENV-Var fuer Skills — Skill ruft Brain fuer frischen Token statt client_secret hardzucoden
- `config_schema` in skill.json + zentrales `/shared/config/skill_configs.json` + `CFG_<NAME>` ENV beim Run + `skill_set_config` Brain-Tool + UI in Diagnostic & App (TextInput / Switch / password-Felder mit `***SET***`-Masking)
- Versionierung: jeder `skill_update` archiviert vorherigen Stand nach `versions/v_<ts>/` (ohne venv/logs). `skill_list_versions` + `skill_rollback` Brain-Tools (mit Safety-Snapshot + auto venv-Rebuild). UI mit Rollback-Button in Diagnostic & App
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
### Phase 2 — ARIA wird produktiv
+69 -2
View File
@@ -6,14 +6,17 @@
*/
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 { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import ChatScreen from './src/screens/ChatScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import rvs from './src/services/rvs';
import { initLogger } from './src/services/logger';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
import gpsTrackingService from './src/services/gpsTracking';
// --- Navigation ---
@@ -49,6 +52,9 @@ const App: React.FC = () => {
// initLogger ist async aber blockt nichts — solange er noch laueft,
// loggen wir normal (Default an), danach respektiert console.log das Setting.
initLogger().catch(() => {});
// Crash-Reporter installieren — ungefangene JS-Errors landen via RVS
// bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API)
installGlobalCrashReporter();
const initConnection = async () => {
const config = await rvs.loadConfig();
if (config) {
@@ -58,8 +64,69 @@ 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();
};
}, []);
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10302
versionName "0.1.3.2"
versionCode 10806
versionName "0.1.8.6"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -9,14 +9,26 @@
<!-- 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). -->
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"
@@ -52,6 +64,6 @@
<service
android:name=".AriaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone" />
android:foregroundServiceType="mediaPlayback|microphone|location" />
</application>
</manifest>
@@ -5,9 +5,11 @@ 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
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
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) {
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
}
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() {
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
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)
}
/** 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
@@ -140,6 +192,10 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
*
* 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) {
@@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
add(ApkInstallerPackage())
add(AudioFocusPackage())
add(PcmStreamPlayerPackage())
add(PcmStreamRecorderPackage())
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
@@ -4,6 +4,7 @@ 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.AudioRecord
@@ -11,6 +12,7 @@ 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.Log
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Promise
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
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
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
running.set(true)
record.startRecording()
// 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}")
}
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
isDaemon = true
start()
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
Log.i(TAG, "Lauschen gestoppt")
promise.resolve(true)
}
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
try { audioRecord?.release() } catch (_: Exception) {}
audioRecord = null
releaseAudioEffects()
releaseWakeLock()
disposeSessions()
promise.resolve(true)
}
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)
@@ -361,6 +361,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
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) {}
}
@@ -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()
}
}
@@ -15,6 +15,7 @@ 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
@@ -35,6 +36,11 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
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) {
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
handleStateChange(state)
}
}
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
tm.registerTelephonyCallback(callbackExecutor, cb)
modernCallback = cb
} else {
@Suppress("DEPRECATION")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.3.2",
"version": "0.1.8.6",
"private": true,
"scripts": {
"android": "react-native run-android",
+89
View File
@@ -0,0 +1,89 @@
/**
* ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box
* statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt,
* der das ueber RVS an die Bridge weiterleitet.
*
* Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze
* App killt.
*/
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { reportAppError } from '../services/logger';
interface Props {
children: React.ReactNode;
/** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */
scope?: string;
/** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */
onReset?: () => void;
}
interface State {
err: Error | null;
info: string;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { err: null, info: '' };
}
static getDerivedStateFromError(err: Error): Partial<State> {
return { err };
}
componentDidCatch(err: Error, info: any) {
const stack = info?.componentStack || '';
this.setState({ info: stack });
reportAppError({
scope: this.props.scope || 'ErrorBoundary',
message: err?.message || String(err),
stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack,
});
}
render() {
if (this.state.err) {
return (
<View style={s.box}>
<Text style={s.title}>⚠️ Etwas ist schiefgegangen</Text>
<Text style={s.scope}>{this.props.scope || 'unbekannte Komponente'}</Text>
<ScrollView style={s.scroll}>
<Text style={s.msg}>{this.state.err.message || String(this.state.err)}</Text>
{this.state.info ? <Text style={s.stack}>{this.state.info}</Text> : null}
</ScrollView>
{this.props.onReset ? (
<TouchableOpacity style={s.btn} onPress={() => { this.setState({err:null,info:''}); this.props.onReset?.(); }}>
<Text style={s.btnText}>Schliessen + zurueck</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={s.btn} onPress={() => this.setState({err:null,info:''})}>
<Text style={s.btnText}>Erneut versuchen</Text>
</TouchableOpacity>
)}
<Text style={s.hint}>
Crash wurde an die Bridge gemeldet — sichtbar in der Diagnostic-Web-UI unter /api/app-log
</Text>
</View>
);
}
return this.props.children;
}
}
const s = StyleSheet.create({
box: { flex:1, padding:16, backgroundColor:'#1A0A0A' },
title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 },
scope: { color:'#FF9500', fontSize:12, marginBottom:10 },
scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 },
msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 },
stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' },
btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' },
btnText: { color:'#fff', fontWeight:'600' },
hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' },
});
export default ErrorBoundary;
+68 -33
View File
@@ -37,9 +37,11 @@ interface Props {
title?: string;
/** Style-Erweiterung fuer den Container. */
flatStyle?: boolean;
/** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */
onOpenMemory?: (id: string) => void;
}
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle }) => {
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
const [items, setItems] = useState<Memory[]>([]);
const [filtered, setFiltered] = useState<Memory[]>([]);
const [loading, setLoading] = useState(false);
@@ -82,38 +84,35 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
setFiltered(out);
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
const [newMemoryTitle, setNewMemoryTitle] = useState('');
const onAddNew = () => {
Alert.prompt(
'Neue Memory',
'Titel:',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Anlegen',
onPress: async (title?: string) => {
const t = (title || '').trim();
if (!t) return;
try {
const m = await brainApi.saveMemory({
type: 'fact', title: t,
content: '(noch leer — bitte editieren)',
});
load();
setOpenId(m.id);
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
},
},
],
'plain-text',
);
setNewMemoryTitle('');
setShowNewMemoryDialog(true);
};
const confirmAddNew = async () => {
const t = newMemoryTitle.trim();
if (!t) { setShowNewMemoryDialog(false); return; }
setShowNewMemoryDialog(false);
try {
const m = await brainApi.saveMemory({
type: 'fact', title: t,
content: '(noch leer — bitte editieren)',
});
load();
if (onOpenMemory) onOpenMemory(m.id);
else setOpenId(m.id);
} catch (e: any) {
Alert.alert('Fehler', String(e?.message || e));
}
};
const renderItem = ({ item }: { item: Memory }) => {
const attCount = (item.attachments || []).length;
return (
<TouchableOpacity style={s.row} onPress={() => setOpenId(item.id)}>
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
<View style={{flex:1}}>
<Text style={s.rowTitle} numberOfLines={1}>
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
@@ -170,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
data={filtered}
keyExtractor={m => m.id}
renderItem={renderItem}
// nestedScrollEnabled: notwendig damit die FlatList auf Android
// scrollt wenn sie in einer aeusseren ScrollView haengt (Settings-
// Screen ist ScrollView). Ohne das frisst der aeussere ScrollView
// alle Gesten und die innere Liste ist tot.
nestedScrollEnabled={true}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
@@ -202,12 +207,42 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
</TouchableOpacity>
</Modal>
<MemoryDetailModal
memoryId={openId}
visible={!!openId}
onClose={() => { setOpenId(null); load(); }}
onDeleted={() => { setOpenId(null); load(); }}
/>
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
(vermeidet Modal-in-Modal-Stacking auf Android). */}
{!onOpenMemory && (
<MemoryDetailModal
memoryId={openId}
visible={!!openId}
onClose={() => { setOpenId(null); load(); }}
onDeleted={() => { setOpenId(null); load(); }}
/>
)}
{/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */}
<Modal visible={showNewMemoryDialog} transparent animationType="fade" onRequestClose={() => setShowNewMemoryDialog(false)}>
<View style={s.menuBack}>
<View style={[s.menuBox, {padding:16, minWidth:280}]}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:14, marginBottom:10}}>Neue Memory anlegen</Text>
<Text style={{color:'#8888AA', fontSize:11, marginBottom:6}}>Titel:</Text>
<TextInput
value={newMemoryTitle}
onChangeText={setNewMemoryTitle}
autoFocus
placeholder="z.B. Stefans Auto"
placeholderTextColor="#555570"
style={{backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:4, fontSize:13, marginBottom:12}}
/>
<View style={{flexDirection:'row', gap:8, justifyContent:'flex-end'}}>
<TouchableOpacity onPress={() => setShowNewMemoryDialog(false)} style={{padding:8}}>
<Text style={{color:'#8888AA'}}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity onPress={confirmAddNew} style={{backgroundColor:'#0096FF', paddingHorizontal:14, paddingVertical:8, borderRadius:4}}>
<Text style={{color:'#fff', fontWeight:'600'}}>Anlegen</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
+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;
+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;
+583
View File
@@ -0,0 +1,583 @@
/**
* 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';
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]);
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;
+51 -72
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,
}) => {
@@ -45,6 +55,21 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
const pulseAnim = useRef(new Animated.Value(1)).current;
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// 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(() => {
if (isRecording) {
@@ -71,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();
@@ -89,74 +113,28 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
clearInterval(durationTimer.current);
durationTimer.current = null;
}
setMeterDb(-160);
}
}, [isRecording]);
// VAD Silence Callback — Auto-Stop.
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
// feuert (silenceFired-Latch).
const onCompleteRef = useRef(onRecordingComplete);
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
useEffect(() => {
const unsubSilence = audioService.onSilenceDetected(async () => {
if (audioService.getRecordingState() !== 'recording') return;
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 500) {
onCompleteRef.current(result);
}
});
return unsubSilence;
}, []);
// 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) {
setIsRecording(true);
}
}, [disabled, isRecording]);
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
// Guard gegen Doppel-Tap während asyncer Start/Stop.
// Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop.
const tapBusy = useRef(false);
const handleTap = async () => {
const handleTap = useCallback(async () => {
if (disabled || tapBusy.current) return;
tapBusy.current = true;
try {
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
// Service-State fragen statt React-State (Closure koennte stale sein)
const svcState = audioService.getRecordingState();
if (svcState === 'recording') {
// Aufnahme manuell stoppen
const result = await audioService.stopRecording();
setIsRecording(false);
if (result && result.durationMs > 300) {
onRecordingComplete(result);
}
await onTapStop();
} else if (svcState === 'idle') {
// Aufnahme mit Auto-Stop starten
const started = await audioService.startRecording(true);
if (started) {
setIsRecording(true);
}
await onTapStart();
}
// svcState === 'processing': Stopp in progress — nichts tun, User
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
// kurz damit der User's UI-Feedback synchron bleibt.
// 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen
} finally {
tapBusy.current = false;
}
};
// 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);
@@ -164,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 (
@@ -198,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({
File diff suppressed because it is too large Load Diff
+248 -5
View File
@@ -19,6 +19,8 @@ import {
ActivityIndicator,
Modal,
PermissionsAndroid,
useWindowDimensions,
DeviceEventEmitter,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
@@ -51,9 +53,17 @@ import {
TTS_SPEED_STORAGE_KEY,
} from '../services/audio';
import audioService from '../services/audio';
import gpsTrackingService from '../services/gpsTracking';
import gpsTrackingService, {
isBackgroundGpsEnabled,
setBackgroundGpsEnabled,
ensureBackgroundLocationPermission,
} from '../services/gpsTracking';
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
import MemoryBrowser from '../components/MemoryBrowser';
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
import TriggerBrowser from '../components/TriggerBrowser';
import SkillBrowser from '../components/SkillBrowser';
import OAuthBrowser from '../components/OAuthBrowser';
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
import {
isWakeReadySoundEnabled,
setWakeReadySoundEnabled,
@@ -102,6 +112,9 @@ const SETTINGS_SECTIONS = [
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
] as const;
@@ -118,6 +131,7 @@ const SOURCE_COLORS: Record<string, string> = {
// --- Komponente ---
const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
const [manualHost, setManualHost] = useState('');
@@ -125,6 +139,9 @@ const SettingsScreen: React.FC = () => {
const [currentMode, setCurrentMode] = useState('normal');
const [gpsEnabled, setGpsEnabled] = useState(false);
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -144,6 +161,7 @@ const SettingsScreen: React.FC = () => {
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
const [debugLogsToBridge, setDebugLogsToBridgeState] = useState<boolean>(isDebugLogsToBridge());
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
@@ -192,11 +210,21 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
if (saved !== null) setGpsEnabled(saved === 'true');
});
AsyncStorage.getItem('aria_background_mode').then(saved => {
// Default ist an — nur explicit 'false' deaktiviert
setBackgroundMode(saved !== 'false');
});
AsyncStorage.getItem('aria_show_hints').then(saved => {
// Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true');
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
gpsTrackingService.restoreFromStorage().catch(() => {});
// Background-GPS-Toggle initial laden
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
@@ -361,6 +389,19 @@ const SettingsScreen: React.FC = () => {
setConnLog(prev => [...prev.slice(-99), entry]);
});
// Lokale App-Logs (reportAppDebug/Error) im Live-Logs-Tab anzeigen
// — damit Stefan ohne curl direkt in der App sieht was passiert.
const localLogSub = DeviceEventEmitter.addListener(APP_LOG_EVENT, (e: any) => {
const entry: LogEntry = {
id: `applog_${e.ts || Date.now()}_${logIdCounter++}`,
timestamp: e.ts || Date.now(),
source: e.scope || 'app',
message: e.message || '',
level: e.level || 'info',
};
setLogs(prev => [...prev.slice(-200), entry]);
});
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
if (message.type === 'log') {
const entry: LogEntry = {
@@ -496,6 +537,7 @@ const SettingsScreen: React.FC = () => {
unsubState();
unsubMessage();
unsubLog();
localLogSub.remove();
};
}, []);
@@ -575,6 +617,44 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
}, []);
// --- Hintergrund-Modus Toggle ---
const handleBackgroundModeToggle = useCallback(async (value: boolean) => {
setBackgroundMode(value);
AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {});
try {
if (value) {
// Permission fuer Notification (Android 13+) — sonst sieht der User
// den Hintergrund-Modus nicht und wundert sich
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as any,
{
title: 'Hintergrund-Modus',
message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.',
buttonPositive: 'Erlauben',
buttonNegative: 'Spaeter',
},
);
}
await acquireBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT);
} else {
await releaseBackgroundAudio('background');
ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT);
}
} catch (err: any) {
console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err);
}
}, []);
// --- System-Hints Toggle ---
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
setShowSystemHints(value);
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -868,7 +948,15 @@ const SettingsScreen: React.FC = () => {
})()}
</View>
</Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
nestedScrollEnabled={true}
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
// scrolling laesst sonst nur in eine Richtung scrollen.
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
>
{currentSection === null && (
<>
@@ -1052,6 +1140,101 @@ const SettingsScreen: React.FC = () => {
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
/>
</View>
{/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION
(User muss in Android-Settings 'Immer erlauben' aktivieren). */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>GPS auch im Hintergrund</Text>
<Text style={styles.toggleHint}>
Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die
App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus.
{'\n\n'}
Android verlangt fuer Background-GPS, dass du in den
System-Einstellungen unter Standort "Immer erlauben" auswaehlst.
Beim Aktivieren wird Android-Settings geoeffnet falls noetig.
{'\n\n'}
Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling.
</Text>
</View>
<Switch
value={bgGpsEnabled}
onValueChange={async (v) => {
if (v) {
const ok = await ensureBackgroundLocationPermission();
if (!ok) {
// User muss in Android-Settings auf "Immer erlauben" — Toggle
// bleibt aus bis er zurueckkommt und nochmal tippt.
return;
}
await setBackgroundGpsEnabled(true);
setBgGpsEnabled(true);
// Wenn Tracking bereits laeuft: neu starten damit der
// Foreground-Service jetzt mit location-Slot kommt
if (gpsTrackingService.isActive()) {
gpsTrackingService.stop('bg-toggle');
gpsTrackingService.start('bg-aktiviert').catch(() => {});
}
ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT);
} else {
await setBackgroundGpsEnabled(false);
setBgGpsEnabled(false);
ToastAndroid.show('Background-GPS aus — nur noch Foreground', ToastAndroid.SHORT);
}
}}
trackColor={{ false: '#2A2A3E', true: '#FF3B30' }}
thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Bubble-Anzeige === */}
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
<Text style={styles.toggleHint}>
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
trotzdem, sie sind nur fuer dich nicht relevant.
</Text>
</View>
<Switch
value={showSystemHints}
onValueChange={handleShowSystemHintsToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Hintergrund-Modus === */}
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
<View style={styles.card}>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>App im Hintergrund weiterlaufen</Text>
<Text style={styles.toggleHint}>
Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert
ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher-
Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar
waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground-
Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut.
{'\n\n'}
Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen:
Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt"
setzen.
</Text>
</View>
<Switch
value={backgroundMode}
onValueChange={handleBackgroundModeToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={backgroundMode ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
</>)}
@@ -1682,11 +1865,50 @@ const SettingsScreen: React.FC = () => {
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
Tags. Neue Einträge anlegen via "+ Neu".
</Text>
<View style={{height: 600, marginBottom: 8}}>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<MemoryBrowser />
</View>
</>)}
{/* === Trigger === */}
{currentSection === 'triggers' && (<>
<Text style={styles.sectionTitle}>Trigger</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<TriggerBrowser />
</View>
</>)}
{/* === Skills === */}
{currentSection === 'skills' && (<>
<Text style={styles.sectionTitle}>Skills</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast.
Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via
ihr skill_update Brain-Tool — hier nur Manifest-Felder + Run + Cleanup.
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<SkillBrowser />
</View>
</>)}
{/* === OAuth-Apps === */}
{currentSection === 'oauth' && (<>
<Text style={styles.sectionTitle}>OAuth-Apps</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...).
Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein,
dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen —
ARIA kann das auch selbst per Chat ("verbinde mich mit X").
</Text>
<View style={{height: winDims.height - 220, marginBottom: 8}}>
<OAuthBrowser />
</View>
</>)}
{/* === Logs === */}
{currentSection === 'protocol' && (<>
<Text style={styles.sectionTitle}>Protokoll</Text>
@@ -1710,6 +1932,27 @@ const SettingsScreen: React.FC = () => {
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
Debuggen via adb logcat.
</Text>
{/* Debug-Logs an Bridge: scharf nur wenn aktiv gebraucht */}
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<Text style={styles.toggleLabel}>Debug-Logs an Bridge</Text>
<Switch
value={debugLogsToBridge}
onValueChange={(v) => {
setDebugLogsToBridge(v);
setDebugLogsToBridgeState(v);
}}
trackColor={{ false: '#3A3A52', true: '#FF9500' }}
thumbColor={debugLogsToBridge ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={styles.toggleHint}>
Schickt detaillierte Diagnose-Logs (Wake-Word-Pipeline, Audio-Focus,
Background-Service) per RVS an die Bridge — abrufbar via
`curl /api/app-log?lines=N` ohne ADB. Default AUS damit kein
unnoetiger Traffic + Disk-Schreiben. Crash-Reports (Errors) gehen
IMMER, dieser Toggle betrifft nur Info-Logs.
</Text>
</View>
<View style={styles.card}>
@@ -1798,7 +2041,7 @@ const SettingsScreen: React.FC = () => {
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
<Text style={styles.aboutInfo}>
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
Stefans Kommandozentrale.{'\n'}
Gebaut mit React Native + TypeScript.
</Text>
+426 -7
View File
@@ -36,10 +36,11 @@ function btoaSafe(bin: string): string {
}
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
AudioFocus?: {
requestDuck: () => Promise<boolean>;
requestExclusive: () => Promise<boolean>;
nudgeMediaResume: () => Promise<boolean>;
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>;
@@ -50,8 +51,15 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
end: () => Promise<boolean>;
stop: () => Promise<boolean>;
};
PcmStreamRecorder?: {
start: () => Promise<boolean>;
stop: () => Promise<boolean>;
isRecording: () => Promise<boolean>;
};
};
import rvs from './rvs';
// --- Typen ---
export interface RecordingResult {
@@ -69,6 +77,19 @@ type RecordingStateCallback = (state: RecordingState) => void;
type MeterCallback = (db: number) => void;
type SilenceCallback = () => void;
/** Endpoint-Event von der Streaming-Whisper-Bridge — finaler Text +
* Echo-Felder. ChatScreen reagiert darauf wie frueher auf
* onSilenceDetected, nur dass der Text schon da ist. */
export interface SttEndpointEvent {
audioRequestId: string;
text: string;
reason: string; // 'endpoint' | 'stream_end' | 'hardcap'
durationS: number;
sttMs: number;
}
type SttEndpointCallback = (e: SttEndpointEvent) => void;
type SttPartialCallback = (text: string) => void;
// --- Konstanten ---
const AUDIO_SAMPLE_RATE = 16000;
@@ -285,6 +306,30 @@ class AudioService {
// Position-Berechnen vom playbackStarted abziehen
private readonly LEADING_SILENCE_SEC = 0.3;
// ── Streaming-STT-Session-State ──
// Aktuelle Session-ID (requestId der whisper-bridge). Leer wenn kein Stream
// aktiv. Wird beim Eintreffen von Chunks geprueft damit wir nicht versehent-
// lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: string = '';
private streamAudioRequestId: string = '';
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
private streamEndpointFired: boolean = false;
// Subscriber-Handles fuer Native-Events + RVS-Listener (cleanup beim stop)
private streamPcmChunkSub: { remove: () => void } | null = null;
private streamPcmErrorSub: { remove: () => void } | null = null;
private streamRvsUnsub: (() => void) | null = null;
// No-speech-Watchdog: wenn nach N ms noch kein einziger stt_partial kam,
// brechen wir die Session ab (Stille → User hat nix gesagt → Konversation
// beenden). Ersetzt den alten vad noSpeechTimer.
private streamNoSpeechTimer: ReturnType<typeof setTimeout> | null = null;
private streamGotPartial: boolean = false;
private streamHardCapTimer: ReturnType<typeof setTimeout> | null = null;
// Endpoint/Partial-Callbacks fuer ChatScreen
private endpointListeners: SttEndpointCallback[] = [];
private partialListeners: SttPartialCallback[] = [];
constructor() {
this.recorder = new AudioRecorderPlayer();
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
@@ -296,8 +341,21 @@ class AudioService {
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamPlayer as any);
emitter.addListener('PcmPlaybackFinished', () => {
console.log('[Audio] PcmPlaybackFinished — Focus jetzt freigeben');
console.log('[Audio] PcmPlaybackFinished — AudioTrack drained');
this._releaseFocusDeferred();
// Erst HIER playbackFinished-Listener feuern — nicht schon beim
// Empfang des letzten PCM-Chunks (siehe handlePcmChunk). AudioTrack
// braucht nach end() noch 1-2s zum Drainen seines Hardware-Buffers.
// Wenn wir die Listener zu frueh feuern, re-armt OpenWakeWord
// waehrend ARIA noch hoerbar spricht → ARIAs Stimme verwirrt die
// Wake-Word-Detection (kein gemeinsames AEC zwischen AudioTrack-
// und AudioRecord-Session). Stefan-Reproduktion: nach jeder ARIA-
// Antwort schluckte das Wake-Word den naechsten Trigger.
import('./logger').then(m => m.reportAppDebug('audio.playback',
'PcmPlaybackFinished native event → fire listeners')).catch(()=>{});
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
});
} catch (err) {
console.warn('[Audio] PcmPlaybackFinished-Subscription fehlgeschlagen:', err);
@@ -309,6 +367,58 @@ class AudioService {
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
// RVS-Listener fuer Streaming-STT-Antworten der Whisper-Bridge.
// Wir subscribed permanent — gefiltert wird ueber streamRequestId-Match.
// Das macht startStreamingRecording einfacher (kein subscribe/unsubscribe
// pro Session noetig).
try {
this.streamRvsUnsub = rvs.onMessage((msg) => {
const t = msg?.type;
if (t !== 'stt_partial' && t !== 'stt_endpoint' && t !== 'stt_stream_done') return;
const p = (msg as any).payload || {};
const reqId = String(p.requestId || '');
if (!reqId || reqId !== this.streamRequestId) return;
if (t === 'stt_partial') {
const text = String(p.text || '');
this.streamGotPartial = true;
// Sobald wir ueberhaupt mal Text gekriegt haben, ist der no-speech
// Watchdog erledigt.
if (this.streamNoSpeechTimer) {
clearTimeout(this.streamNoSpeechTimer);
this.streamNoSpeechTimer = null;
}
this.partialListeners.forEach(cb => {
try { cb(text); } catch (e) { console.warn('[Audio] partial listener err:', e); }
});
return;
}
if (t === 'stt_endpoint') {
const ev: SttEndpointEvent = {
audioRequestId: String(p.audioRequestId || ''),
text: String(p.text || ''),
reason: String(p.reason || ''),
durationS: Number(p.durationS || 0),
sttMs: Number(p.sttMs || 0),
};
console.log('[Audio] stt_endpoint: %dms, %.1fs Audio, text=%r',
ev.sttMs, ev.durationS, ev.text.slice(0, 80));
// Wir stoppen die Aufnahme — whisper hat alles was es braucht.
// Kein stt_stream_end senden: das Endpoint kam von der Bridge,
// sie hat schon finalisiert.
this._fireEndpoint(ev);
this._cleanupStreamLocal('endpoint');
return;
}
if (t === 'stt_stream_done') {
// Idempotent — falls cleanup nach endpoint schon lief, harmlos.
this._cleanupStreamLocal('stream_done');
return;
}
});
} catch (err) {
console.warn('[Audio] RVS-Listener-Subscribe fehlgeschlagen:', err);
}
}
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
@@ -332,6 +442,13 @@ class AudioService {
}
console.log('[Audio] AudioFocus jetzt released');
AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -727,6 +844,31 @@ class AudioService {
}
}
/** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei
* Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund.
* Aufgenommene Datei wird sofort verworfen. */
async cancelRecording(): Promise<void> {
if (this.recordingState !== 'recording') return;
console.log('[Audio] Aufnahme abgebrochen (cancel)');
this.vadEnabled = false;
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
try {
const path = await this.recorder.stopRecorder();
this.recorder.removeRecordBackListener();
// Datei loeschen wenn da
if (path && path !== 'Already stopped') {
const local = path.replace(/^file:\/\//, '');
try { await RNFS.unlink(local); } catch {}
}
} catch (err) {
console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err);
}
this._releaseFocusDeferred();
this.setState('idle');
}
/** Aufnahme stoppen und Ergebnis zurueckgeben */
async stopRecording(): Promise<RecordingResult | null> {
if (this.recordingState !== 'recording') {
@@ -789,6 +931,282 @@ class AudioService {
}
}
// ──────────────────────────────────────────────────────────────
// STREAMING-AUFNAHME (Phase 1+2 — PCM live an Whisper-Bridge)
// ──────────────────────────────────────────────────────────────
/** Startet eine Streaming-STT-Session.
*
* Statt eine MP4-Datei aufzunehmen und am Ende hochzuladen, oeffnet der
* PcmStreamRecorder (16 kHz mono s16le) ein AudioRecord und schickt
* alle 200ms einen PCM-Chunk via rvs.send('stt_audio_chunk') an die
* whisper-bridge. Diese transkribiert live und feuert stt_endpoint
* sobald der erkannte Text fuer endpointMs nicht mehr waechst.
*
* Auf stt_endpoint reagiert audio.ts indem es PcmStreamRecorder stoppt
* und endpointListeners feuert — ChatScreen baut dann die Chat-Bubble.
* Den eigentlichen Brain-Call macht aria-bridge direkt nach stt_endpoint,
* KEIN Audio-Roundtrip ueber die App noetig.
*
* Args:
* audioRequestId — eindeutige Korrelations-ID fuer die "wird
* verarbeitet"-Bubble (gleiche Semantik wie beim
* Legacy-Pfad mit rvs.send('audio')).
* voice/speed — TTS-Echo-Felder, werden an Brain weitergegeben.
* interrupted — true bei Barge-In waehrend ARIA noch sprach.
* location — GPS, falls vorhanden.
* noSpeechTimeoutMs — wenn nach so vielen ms KEIN stt_partial kam
* (= Whisper hat nix erkannt), brechen wir die
* Session ab. 0 = kein Watchdog.
* endpointMs — Schwellwert fuer Endpoint (Stille = kein neuer
* Text). Default 1500ms — Whisper-Bridge nutzt
* den Wert wenn mitgesendet.
* hardCapMs — Schmerzgrenze. Default 60s.
*/
async startStreamingRecording(opts: {
audioRequestId: string;
voice?: string;
speed?: number;
interrupted?: boolean;
location?: any;
noSpeechTimeoutMs?: number;
endpointMs?: number;
hardCapMs?: number;
}): Promise<{ requestId: string; ok: boolean }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
}
if (!PcmStreamRecorder) {
console.warn('[Audio] PcmStreamRecorder Native-Modul nicht verfuegbar');
return { requestId: '', ok: false };
}
const hasPermission = await this.requestMicrophonePermission();
if (!hasPermission) {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
this.streamAudioRequestId = opts.audioRequestId || '';
this.streamGotPartial = false;
this.streamEndpointFired = false;
this.recordingStartTime = Date.now();
try {
await acquireBackgroundAudio('rec');
// PcmStreamChunk-Subscriber AUFSETZEN BEVOR der Recorder startet —
// sonst koennten die ersten 1-2 Chunks ins Leere gehen.
try {
const emitter = new NativeEventEmitter(NativeModules.PcmStreamRecorder as any);
this.streamPcmChunkSub = emitter.addListener('PcmStreamChunk', (e: any) => {
// Nur Chunks der aktuellen Session weiterleiten — verhindert dass
// ein verspaeteter Chunk in einer neuen Session landet.
if (!this.streamRequestId) return;
const sessionId = this.streamRequestId;
rvs.send('stt_audio_chunk' as any, {
requestId: sessionId,
pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0),
});
});
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
console.warn('[Audio] PcmStreamRecorder-Fehler:', e?.error);
this._cleanupStreamLocal('pcm-error');
});
} catch (err) {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
voice: opts.voice || '',
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
interrupted: !!opts.interrupted,
location: opts.location || null,
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
const hardCapMs = Number(opts.hardCapMs || 60000);
this.streamHardCapTimer = setTimeout(() => {
if (this.streamRequestId === requestId) {
console.log('[Audio] Stream %s: app-side hardcap %dms erreicht → end',
requestId.slice(0, 12), hardCapMs);
this.stopStreamingRecording('hardcap').catch(() => {});
}
}, hardCapMs + 2000); // +2s damit Bridge zuerst feuert wenn moeglich
console.log('[Audio] Streaming-Aufnahme gestartet (requestId=%s, audioRequestId=%s)',
requestId.slice(0, 12), (opts.audioRequestId || '').slice(0, 16));
return { requestId, ok: true };
} catch (err) {
console.error('[Audio] startStreamingRecording fehlgeschlagen:', err);
this._cleanupStreamLocal('start-failed');
return { requestId: '', ok: false };
}
}
/** Sauberer User-initiated Stop. Sendet stt_stream_end an die Bridge,
* die noch ihren Final-Transcribe macht.
*
* Plus: Fallback-Timer (3s). Wenn die Bridge nicht antwortet (z.B. weil
* veraltete Version ohne Streaming-Handler laeuft), feuern wir den
* Endpoint-Listener trotzdem mit text='' damit die App-UI nicht in
* "wird verarbeitet..." haengt. ChatScreen behandelt das wie den
* No-Speech-Fall (Bubble weg + endConversation). */
async stopStreamingRecording(reason: string = 'user'): Promise<void> {
const reqId = this.streamRequestId;
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
} catch (e) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
}
// Recorder lokal abschalten — Bridge feuert dann ihrerseits noch
// stt_endpoint + stt_stream_done.
this._cleanupStreamLocal(`stop:${reason}`);
// Fallback-Watchdog: nach 3s noch immer kein Endpoint via RVS angekommen
// → _fireEndpoint mit text='' (idempotent via streamEndpointFired-Latch,
// d.h. wenn echtes stt_endpoint zwischen jetzt und +3s ankommt feuert
// dieser Fallback NICHT).
setTimeout(() => {
if (this.streamEndpointFired) return;
console.log('[Audio] stopStreamingRecording: 3s ohne Bridge-Antwort — fallback fire');
this._fireEndpoint({
audioRequestId: audioReqId,
text: '',
reason: `stop:${reason}:no-response`,
durationS: 0,
sttMs: 0,
});
}, 3000);
}
/** Abbruch ohne dass Brain den Text verarbeitet — z.B. wenn der User
* im Conversation-Window nichts sagt oder cancel drueckt.
*
* Feuert endpointListeners mit text='' damit ChatScreen den Fall genauso
* behandeln kann wie frueher onSilenceDetected→stopRecording()→null:
* Konversation beenden, Ohr zurueck auf armed. */
async cancelStreamingRecording(reason: string = 'cancel'): Promise<void> {
const reqId = this.streamRequestId;
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
} catch {}
this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
this._fireEndpoint({
audioRequestId: audioReqId,
text: '',
reason: `cancel:${reason}`,
durationS: 0,
sttMs: 0,
});
}
/** Feuert den Endpoint-Listener — aber nur einmal pro Session-Cycle.
* Wird sowohl vom RVS-stt_endpoint-Pfad als auch vom Fallback-Watchdog
* und cancelStreamingRecording aufgerufen. */
private _fireEndpoint(ev: SttEndpointEvent): void {
if (this.streamEndpointFired) return;
this.streamEndpointFired = true;
this.endpointListeners.forEach(cb => {
try { cb(ev); } catch (e) { console.warn('[Audio] endpoint listener err:', e); }
});
}
/** Nur-lokale Cleanup: PcmStreamRecorder stoppen, Listener entfernen,
* AudioFocus freigeben, State zurueck auf idle. Nicht ueber RVS
* kommunizieren — Caller hat das schon erledigt (oder eben nicht
* noetig wenn Bridge das Endpoint gefeuert hat). */
private _cleanupStreamLocal(reason: string): void {
if (!this.streamRequestId) return;
console.log('[Audio] Stream cleanup (%s)', reason);
this.streamRequestId = '';
this.streamAudioRequestId = '';
this.streamGotPartial = false;
if (this.streamNoSpeechTimer) {
clearTimeout(this.streamNoSpeechTimer);
this.streamNoSpeechTimer = null;
}
if (this.streamHardCapTimer) {
clearTimeout(this.streamHardCapTimer);
this.streamHardCapTimer = null;
}
if (this.streamPcmChunkSub) {
try { this.streamPcmChunkSub.remove(); } catch {}
this.streamPcmChunkSub = null;
}
if (this.streamPcmErrorSub) {
try { this.streamPcmErrorSub.remove(); } catch {}
this.streamPcmErrorSub = null;
}
PcmStreamRecorder?.stop().catch(() => {});
this._releaseFocusDeferred();
this.setState('idle');
}
/** True wenn aktuell eine Streaming-Session laeuft. */
isStreamingRecording(): boolean {
return !!this.streamRequestId;
}
/** Subscribe auf stt_endpoint — feuert wenn die Whisper-Bridge erkannt
* hat, dass der User fertig gesprochen hat (ML-Endpointer). */
onSttEndpoint(callback: SttEndpointCallback): () => void {
this.endpointListeners.push(callback);
return () => {
this.endpointListeners = this.endpointListeners.filter(cb => cb !== callback);
};
}
/** Subscribe auf stt_partial — Live-Transkript-Updates (optional fuer
* UI-Feedback in der Voice-Bubble). */
onSttPartial(callback: SttPartialCallback): () => void {
this.partialListeners.push(callback);
return () => {
this.partialListeners = this.partialListeners.filter(cb => cb !== callback);
};
}
// --- Wiedergabe ---
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
@@ -963,12 +1381,13 @@ class AudioService {
// releasen den AudioFocus NICHT hier — der writer braucht u.U. noch
// 30+ Sekunden bis der Buffer wirklich abgespielt ist. Den release
// triggert das native Event "PcmPlaybackFinished" wenn AudioTrack
// wirklich am Ende ist (siehe ensurePlaybackFinishedListener).
// wirklich am Ende ist (siehe Constructor-PcmPlaybackFinished-Handler).
//
// playbackFinishedListeners feuern AUCH erst dort — frueher feuerten
// sie hier (beim Eintreffen des letzten Chunks), das fuehrte zu
// einem Race: OpenWakeWord re-armte waehrend AudioTrack noch hoerbar
// ARIAs Stimme abspielte → naechstes Wake-Word ging unter.
try { await PcmStreamPlayer!.end(); } catch {}
// playbackFinished-Listener informieren (UI-Logik)
this.playbackFinishedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackFinished cb err:', e); }
});
}
this.pcmStreamActive = false;
+19 -10
View File
@@ -1,17 +1,22 @@
/**
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
* 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 waehrend irgendein Audio-Slot aktiv ist.
* 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)
* - '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).
* den hoechstprioren Slot an (tts > rec > wake > location > background).
*/
import { NativeModules } from 'react-native';
@@ -23,12 +28,13 @@ interface BackgroundAudioNative {
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
type Slot = 'tts' | 'rec' | 'wake';
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
const slots = new Set<Slot>();
// Prioritaet fuer den Notification-Text — hoechste zuerst.
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
// 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) {
@@ -42,6 +48,7 @@ async function applyState(): Promise<void> {
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();
@@ -49,8 +56,10 @@ async function applyState(): Promise<void> {
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(()=>{});
}
}
+326 -15
View File
@@ -54,6 +54,18 @@ function _newRequestId(): string {
return `brain_${Date.now().toString(36)}_${_nextId}`;
}
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
function _qs(params: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
interface SendOpts {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: AnyJson;
@@ -109,6 +121,83 @@ export interface Memory {
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;
}
/** 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 = {
@@ -119,29 +208,31 @@ export const brainApi = {
/** Liste aller Memories, optional nach Type gefiltert. */
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams();
if (opts.type) qs.set('type', opts.type);
qs.set('limit', String(opts.limit || 500));
return _send(`/memory/list?${qs.toString()}`);
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
return _send(`/memory/list${qs}`);
},
/** Volltext-Substring-Suche. */
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q });
if (opts.type) qs.set('type', opts.type);
qs.set('include_pinned', String(opts.includePinned !== false));
qs.set('k', String(opts.k || 50));
return _send(`/memory/search-text?${qs.toString()}`);
const qs = _qs({
q,
type: opts.type,
include_pinned: opts.includePinned !== false,
k: opts.k || 50,
});
return _send(`/memory/search-text${qs}`);
},
/** Semantische Suche (Embedder). */
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
const qs = new URLSearchParams({ q });
if (opts.type) qs.set('type', opts.type);
qs.set('include_pinned', String(opts.includePinned !== false));
qs.set('k', String(opts.k || 10));
qs.set('score_threshold', String(opts.threshold ?? 0.30));
return _send(`/memory/search?${qs.toString()}`);
const qs = _qs({
q,
type: opts.type,
include_pinned: opts.includePinned !== false,
k: opts.k || 10,
score_threshold: opts.threshold ?? 0.30,
});
return _send(`/memory/search${qs}`);
},
/** Memory anlegen. */
@@ -201,6 +292,226 @@ export const brainApi = {
{ 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,
});
},
};
export default brainApi;
+88 -1
View File
@@ -14,9 +14,62 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
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;
@@ -26,6 +79,13 @@ class GpsTrackingService {
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;
@@ -79,11 +139,21 @@ class GpsTrackingService {
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) {
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) => {
@@ -96,6 +166,17 @@ class GpsTrackingService {
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();
@@ -118,6 +199,12 @@ class GpsTrackingService {
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();
+147
View File
@@ -7,8 +7,28 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform, DeviceEventEmitter } 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).
@@ -16,6 +36,7 @@ const originalLog = console.log.bind(console);
const noop = () => {};
let _verbose = true;
let _debugLogsToBridge = false;
function applyState(): void {
console.log = _verbose ? originalLog : noop;
@@ -27,6 +48,10 @@ export async function initLogger(): Promise<void> {
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();
}
@@ -39,3 +64,125 @@ export function setVerboseLogging(verbose: boolean): void {
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();
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 {
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
}
}
+40
View File
@@ -43,6 +43,42 @@ class PhoneCallService {
/** 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;
@@ -82,7 +118,10 @@ class PhoneCallService {
'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');
@@ -108,6 +147,7 @@ class PhoneCallService {
this.started = false;
this.lastState = 'idle';
this.interruptedByFocus = false;
this.telephonyAttached = false;
}
private _onStateChanged(state: PhoneState): void {
+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();
}
+1 -1
View File
@@ -189,7 +189,7 @@ class UpdateService {
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') {
+96 -12
View File
@@ -86,6 +86,11 @@ class WakeWordService {
* 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;
private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false;
@@ -174,6 +179,8 @@ class WakeWordService {
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;
@@ -231,14 +238,24 @@ class WakeWordService {
}
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(); } catch {}
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); }
});
@@ -246,11 +263,16 @@ class WakeWordService {
return;
}
this.setState('conversing');
setTimeout(() => {
if (this.state === 'conversing') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 200);
// 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
@@ -324,32 +346,94 @@ class WakeWordService {
* Ohne: zurueck zu 'off'.
*/
async endConversation(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.state !== 'conversing') {
// Nicht in conversing — typ. nach App-Resume bevor Streaming endete.
// Trotzdem loggen damit wir's im Diagnostic sehen.
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
return;
}
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, nativeReady=${this.nativeReady}, calling OpenWakeWord.start()`)).catch(()=>{});
if (this.nativeReady && OpenWakeWord) {
try {
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, keyword=${this.keyword}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
} 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');
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
/** 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 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') {
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
this.wakeCallbacks.forEach(cb => cb());
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). */
+7
View File
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
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
+819 -43
View File
@@ -18,6 +18,10 @@ from __future__ import annotations
import json
import logging
import os
import re
import urllib.error
import urllib.request
from typing import Optional
from conversation import Conversation, Turn
@@ -27,6 +31,34 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
import oauth as oauth_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
FLUX_HTTP_TIMEOUT_SEC = 1200
# Diagnostic-Settings fuer FLUX (Default-Modell + User-Keywords) liegen im
# selben File wie F5-TTS/Whisper Config — von der aria-bridge geschrieben.
VOICE_CONFIG_PATH = "/shared/config/voice_config.json"
def _load_flux_config() -> dict:
"""Liest fluxXxx-Felder aus der Voice-Config. Default-Werte wenn nichts
persistiert ist — Stefan hat in Diagnostic vielleicht noch nichts gesetzt."""
try:
with open(VOICE_CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
except (FileNotFoundError, json.JSONDecodeError):
data = {}
except Exception as exc:
logger.debug("Voice-Config lesen fehlgeschlagen: %s", exc)
data = {}
return {
"fluxDefaultModel": data.get("fluxDefaultModel", "dev"),
"fluxKeywordRaw": data.get("fluxKeywordRaw", "flux"),
"fluxKeywordSwitch": data.get("fluxKeywordSwitch", "fix"),
}
logger = logging.getLogger(__name__)
@@ -41,6 +73,18 @@ META_TOOLS = [
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
"mit den pip_packages die er braucht.\n\n"
"PFLICHT VORHER:\n"
" - `skill_list` aufrufen und pruefen ob ein passender Skill schon "
"existiert. Wenn ja: `skill_update` statt neu anlegen.\n"
" - Name OHNE Versionssuffix waehlen (kein `-v2`, `_v3`, `-new`, "
"`-fixed`, `-aria`, `-ctl`). Versionsverwaltung ist intern, Du brauchst "
"nur einen klaren Namen.\n"
" - Bei OAuth-Services (Spotify, Google, GitHub etc.): NIEMALS "
"client_id/client_secret/Tokens in den Code schreiben. Nutze "
"`oauth_get_token('<service>')` — das macht Auto-Refresh. Sonst muss "
"Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
@@ -58,14 +102,18 @@ META_TOOLS = [
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kurz, kebab-case, a-z 0-9 - _"},
"name": {"type": "string", "description": "kurz, 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'."},
"description": {"type": "string", "description": "Was kann der Skill? 1 Satz."},
"entry_code": {
"type": "string",
"description": (
"Python-Code. Args lesen via os.environ['ARG_NAME']. "
"Resultat per print() (stdout) zurueck. Bei Fehler: "
"non-zero exit (sys.exit(1) o.ae.)."
"Python-Code. Args lesen via os.environ['ARG_<UPPER_NAME>']. "
"WICHTIG: der Präfix `ARG_` ist Pflicht (Konvention vom "
"Skill-Runner). NIEMALS direkt PATH/METHOD/BODY etc. "
"abrufen — das sind reservierte System-ENV (PATH = "
"Executable-Suchpfad, nicht Dein arg!). Resultat per "
"print() (stdout) zurueck. Bei Fehler: non-zero exit "
"(sys.exit(1) o.ae.)."
),
},
"readme": {"type": "string", "description": "Markdown — was macht der Skill, Beispiel-Aufrufe"},
@@ -92,6 +140,189 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "skill_update",
"description": (
"Aktualisiere einen EXISTIERENDEN Skill statt eine zweite Version "
"mit `-v2`/`-new`/`-fixed` Suffix anzulegen. Stefan hasst Skill-"
"Friedhoefe. Wenn Du `youtube2mp3` umbauen sollst → `skill_update` "
"auf den bestehenden, NICHT `skill_create` mit neuem Namen.\n\n"
"Skills sind GANZ NORMALER Python-Code. Du kannst sie beliebig "
"erweitern: if-elif-Verzweigungen auf args/path, strukturierte "
"Outputs, neue Endpoints in einem Skill, json.loads etc. "
"'Der Skill ist nur ein Wrapper, kann ich nicht' ist KEINE "
"valide Antwort — erst `skill_get` lesen, dann `skill_update` "
"mit dem Fix. Stefan ist kein Python-Entwickler, er nennt das "
"ZIEL, Du baust das WIE.\n\n"
"Du kannst gleichzeitig `entry_code` (Python-Code austauschen), "
"`readme`, `pip_packages` (bei Aenderung wird die venv automatisch "
"neu aufgebaut), `args`, `description` und `active` setzen. Felder "
"die Du weglaesst bleiben unberuehrt.\n\n"
"WENN Du Dir bei einem grundlegenden API-Bruch unsicher bist ob "
"der Skill noch zum Namen passt: lieber `skill_delete` + "
"`skill_create` mit neuem semantischen Namen statt eines "
"halbgaren Updates."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Bestehender Skill-Name"},
"entry_code": {"type": "string", "description": "Neuer Python-Code (optional)"},
"readme": {"type": "string", "description": "Neuer README-Inhalt (optional)"},
"pip_packages": {
"type": "array",
"items": {"type": "string"},
"description": "Neue pip-Pakete (ueberschreibt komplette Liste; triggert venv-Rebuild)",
},
"args": {
"type": "array",
"items": {"type": "object"},
"description": "Neues Args-Schema (optional)",
},
"description": {"type": "string", "description": "Neue Beschreibung (optional)"},
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
"config_schema": {
"type": "array",
"items": {"type": "object"},
"description": (
"Optional neues config_schema fuer den Skill. Liste von "
"Feldern [{name, type, label, secret?, description?, default?}]. "
"type: string|number|boolean|password (password impliziert secret=true). "
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
),
},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_scaffold",
"description": (
"ERSTE WAHL fuer Skill-Bau wenn das Muster zu einem Template passt — "
"Brain expandiert das Skelett, Du sparst Dir das vollstaendige "
"Python-Programm zu generieren. Wenn Stefan eine externe API "
"mehrmals nutzt: SOFORT `skill_scaffold` statt jedes Mal "
"ad-hoc Bash-curl.\n\n"
"Verfuegbare Templates:\n"
" - **oauth-api**: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, …). "
"Token kommt vom Brain mit Auto-Refresh. params: "
"`{service:'spotify', base_url?:'https://...'}`\n"
" - **apikey-api**: API mit statischem Key (OpenWeather, OpenAI, Twilio). "
"Key liegt im skill.json config_schema → CFG_<NAME> ENV. params: "
"`{api_name:'OpenWeather', key_env:'OWM_API_KEY', auth_header?:'Authorization', auth_prefix?:'Bearer ', base_url:'https://...'}`\n"
" - **file-process**: Skelett fuer Datei-In/Datei-Out (PDF, Bild, JSON umformen). "
"process()-Funktion ist Stub — danach `skill_update` mit echtem Code. params: "
"`{output_ext:'txt'}`\n\n"
"Nach Scaffold kannst Du das Skelett via `skill_update` weiter "
"anpassen falls noetig (mehr pip_packages, andere args, …). "
"Aber meistens reicht das Template direkt.\n\n"
"Wenn kein Template passt: erst pruefen ob Du wirklich ein "
"kustomes brauchst, sonst lieber Template + Update."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string",
"description": "Skill-Name (snake_case, NUR a-z 0-9 _, KEINE Bindestriche, ohne Versionssuffix)"},
"template": {"type": "string",
"enum": ["oauth-api", "apikey-api", "file-process"],
"description": "Eines der drei Templates"},
"params": {"type": "object",
"description": "Template-spezifische Parameter (siehe description)"},
},
"required": ["name", "template"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_set_config",
"description": (
"Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
"User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
"im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
"Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
"NICHT in den Skill-Code, sondern hierher.\n\n"
"WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
"aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
"aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name"},
"values": {
"type": "object",
"description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
},
},
"required": ["name", "values"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_list_versions",
"description": (
"Listet archivierte Versionen eines Skills (jeder skill_update "
"legt automatisch eine an). Returns [{version_id, archived_at, "
"summary}]. Brauchst Du fuer skill_rollback."
),
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_rollback",
"description": (
"Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
"wird der aktuelle Stand automatisch archiviert — du verlierst "
"nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
"oder Stefan sagt 'mach den letzten Stand wieder her'. "
"version_id bekommst Du aus skill_list_versions."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"version_id": {"type": "string", "description": "Format v_<timestamp>"},
},
"required": ["name", "version_id"],
},
},
},
{
"type": "function",
"function": {
"name": "skill_delete",
"description": (
"Loescht einen Skill samt venv und Logs. Nutze das wenn:\n"
"1. Stefan explizit sagt der Skill soll weg\n"
"2. Du eine alte Skill-Version losgeworden bist nachdem `skill_create` "
"mit besserem Namen erfolgreich war (Aufraeumen statt Skill-Friedhof)\n"
"3. Ein Skill grundlegend kaputt und ein Update sich nicht mehr lohnt — "
"in dem Fall bestaetige vorher kurz bei Stefan.\n\n"
"Nicht rueckholbar."
),
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
@@ -134,10 +365,19 @@ META_TOOLS = [
"function": {
"name": "trigger_watcher",
"description": (
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
"Lege einen Watcher-Trigger an — pollt eine Condition, "
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
"nur gegen GPS-Jitter).\n"
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
),
"parameters": {
"type": "object",
@@ -206,6 +446,219 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_register_provider",
"description": (
"Registriert einen NEUEN OAuth2-Provider in oauth_apps.json — "
"nutze das wenn Stefan einen Service nutzen will, der noch nicht "
"in der Default-Liste (spotify, google, github, strava, microsoft) "
"ist. Du kennst typische OAuth-Endpunkte aus deinem Training "
"(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, "
"Zoom, Trello, ...). Trag NUR die URLs ein — client_id / "
"client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder "
"App > Settings > OAuth-Apps).\n\n"
"**Workflow bei neuem Service:**\n"
"1. `oauth_register_provider` mit auth_url + token_url + scopes\n"
"2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in "
"Diagnostic/App > OAuth-Apps deine client_id + client_secret aus "
"dem {name}-Developer-Dashboard ein. Plus die Callback-URL "
"{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n"
"3. Warten bis Stefan fertig ist\n"
"4. `oauth_authorize` rufen\n\n"
"**`client_auth`-Wert:** Die meisten Provider wollen client_id+"
"secret im Body (`body`, default). Spotify und manche andere "
"wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, "
"nimm `body` — schlaegt der Token-Request dann mit 401 fehl, "
"switch auf `basic`.\n\n"
"Bei Provider die du wirklich nicht kennst: frag Stefan oder "
"such die Docs raus statt zu raten."
),
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')",
},
"auth_url": {
"type": "string",
"description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'",
},
"token_url": {
"type": "string",
"description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'",
},
"scopes": {
"type": "array",
"items": {"type": "string"},
"description": "Default-Scopes die der User beim Auth zustimmen muss",
},
"client_auth": {
"type": "string",
"enum": ["body", "basic"],
"description": "Wie der Provider client_id/secret erwartet (Default 'body')",
},
},
"required": ["service", "auth_url", "token_url"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_authorize",
"description": (
"Startet einen OAuth2-Authorize-Flow fuer einen externen "
"Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
"Returnt eine URL die Stefan im Browser oeffnen muss — er "
"loggt sich beim Provider ein und stimmt den Scopes zu, der "
"Provider redirected zu unserem RVS-Callback, RVS forwarded "
"an Brain, Token wird automatisch gespeichert.\n\n"
"**Nutze das wenn:** Stefan moechte einen Service nutzen "
"(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
"Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
"**Workflow:**\n"
"1. `oauth_authorize(service='spotify')` -> URL\n"
"2. Gib Stefan die URL als anklickbaren Link\n"
"3. Warte bis er sagt dass er autorisiert hat\n"
"4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
"Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
"Service `client_id` + `client_secret` eingetragen. Falls nicht, "
"wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
"er soll das machen, NICHT versuchen die Credentials selbst zu "
"raten oder zu generieren."
),
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
},
"scopes": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, google, ..."},
},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "oauth_revoke",
"description": (
"Loescht das gespeicherte Token fuer einen Service (lokal). "
"Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
"er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
"\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
),
"parameters": {
"type": "object",
"properties": {"service": {"type": "string"}},
"required": ["service"],
},
},
},
{
"type": "function",
"function": {
"name": "flux_generate",
"description": (
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. "
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
"kurz an, dann ist er nicht ueberrascht.\n\n"
"**Schreibe deine Antwort wie immer auf Deutsch**, und referenziere das "
"fertige Bild MIT dem `[FILE: ...]`-Marker, GENAU im Pfad-Format das das "
"Tool zurueckgibt. Beispiel:\n"
" 'Hier dein Aquarell:\\n[FILE: /shared/uploads/aria_generated_1234.png]'\n\n"
"Der Marker wird beim App-Renderer ausgeblendet und das Bild stattdessen "
"inline als Anhang gezeigt.\n\n"
"**Prompt-Sprache: bevorzugt Englisch.** FLUX versteht zwar Deutsch, "
"liefert aber mit englischen Prompts deutlich konsistentere Ergebnisse. "
"Uebersetze Stefans deutsche Beschreibung selbststaendig — AUSSER `raw=true`.\n\n"
"**Modus `raw=true` (Pipe-Modus):** Wenn Stefan das Raw-Keyword aus dem "
"FLUX-Settings-Block im System-Prompt nutzt (typischerweise `flux`), "
"leite seinen Text 1:1 als prompt durch — KEIN Uebersetzen, KEIN "
"Beautify, KEINE Qualitaets-Keywords. Stefan formuliert dann selbst und "
"der Prompt geht roh an FLUX. Brauchbar wenn er den vollen Output ohne "
"ARIAs Filter haben will.\n\n"
"**Modell-Wahl (`model`):** \n"
"- `default` (oder weglassen): das in den Diagnostic-Settings eingestellte "
"Default-Modell (steht im FLUX-Block im System-Prompt).\n"
"- `dev`: hochqualitatives FLUX.1-dev, 20-90s, ~28 steps.\n"
"- `schnell`: FLUX.1-schnell, 4-step distillation, ~5-15s.\n"
"Wenn Stefan das Switch-Keyword (steht ebenfalls im FLUX-Block) im Prompt "
"verwendet → setze `model` auf das ANDERE Modell als das Default. Bei "
"'in hoher Qualitaet'/'detailliert' → `dev`. Bei 'schnell mal'/'fix' → `schnell`.\n\n"
"Modell-Switch kostet einmalig 15-30s (Pipeline-Reload aus HF-Cache). "
"Stefan sieht den Status im Diagnostic-Banner.\n\n"
"Caps:\n"
"- `width`/`height`: 256-1536, wird auf Vielfache von 64 gesnappt (Default 1024)\n"
"- `steps`: 1-50 (Default 28 fuer dev, 4 fuer schnell)\n"
"- `guidance_scale`: 0.0-20.0 (Default 3.5)\n"
"- `seed`: optional, gleicher seed + gleicher prompt → gleiches Bild"
),
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": (
"Bei raw=false (Default): englischer Bild-Prompt, von dir aus Stefans Worten gebaut, "
"mit Stil/Licht/Kamera-Stichworten. Bei raw=true: Stefans Text 1:1 ohne Aenderung."
),
},
"raw": {
"type": "boolean",
"description": (
"true = Pipe-Modus, kein Rewriting. Setzen wenn Stefan das Raw-Keyword "
"(siehe FLUX-Block im System-Prompt) am Anfang seiner Nachricht verwendet."
),
},
"model": {
"type": "string",
"enum": ["default", "dev", "schnell"],
"description": "Default-Modell oder explizit dev/schnell. Default = Diagnostic-Setting.",
},
"width": {"type": "integer", "description": "Breite in px (Default 1024, max 1536)"},
"height": {"type": "integer", "description": "Hoehe in px (Default 1024, max 1536)"},
"steps": {"type": "integer", "description": "Inference-Steps (Default 28, max 50). Mehr = besser+langsamer."},
"guidance_scale": {"type": "number", "description": "Wie strikt am Prompt kleben (Default 3.5)"},
"seed": {"type": "integer", "description": "Reproduzierbarkeits-Seed (optional)"},
},
"required": ["prompt"],
},
},
},
{
"type": "function",
"function": {
@@ -346,10 +799,18 @@ def _skill_to_tool(s: dict) -> dict:
}
if a.get("required"):
required.append(name)
# Tool-Namen duerfen in der Anthropic/Claude tool_use-API nur
# [a-zA-Z0-9_-]{1,64} sein, aber der claude-max-api-proxy (OpenAI-
# Format-Adapter) ist restriktiver und faellt bei Bindestrichen auf
# die Nase — die GANZE Tool-Liste wird dann verworfen und ARIA
# bekommt "No such tool available". Skill-Namen wie 'yt-dlp-download'
# oder 'pdf-umfrage-generator' muessen daher zu run_yt_dlp_download
# bzw. run_pdf_umfrage_generator gemappt werden.
safe_name = "run_" + re.sub(r"[^a-zA-Z0-9_]", "_", s["name"])
return {
"type": "function",
"function": {
"name": f"run_{s['name']}",
"name": safe_name,
"description": s.get("description", "(ohne Beschreibung)"),
"parameters": {
"type": "object",
@@ -428,10 +889,26 @@ class Agent:
condition_funcs = watcher_mod.describe_functions()
# 5. System-Prompt + Window-Messages
flux_config = _load_flux_config()
# OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
try:
oauth_services = oauth_mod.list_services()
except Exception as exc:
logger.warning("oauth list_services fehlgeschlagen: %s", exc)
oauth_services = None
oauth_host = os.environ.get("RVS_HOST", "").strip()
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
condition_funcs=condition_funcs)
condition_funcs=condition_funcs,
flux_config=flux_config,
oauth_services=oauth_services,
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -440,40 +917,59 @@ class Agent:
len(hot), len(cold), len(active_skills), len(all_skills),
len(self.conversation.window()), len(system_prompt))
# 6. Tool-Use-Loop
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
# ist bereits in der Conversation. Ohne Gegenpart wird die naechste
# Anfrage im Window an Claude geschickt mit user → user als letzten
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = ""
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools)
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
role="assistant",
content=result.content or None,
tool_calls=[{
"id": tc["id"], "type": "function",
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
} for tc in result.tool_calls],
))
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools)
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
role="tool",
tool_call_id=tc["id"],
name=tc["name"],
content=tool_result[:8000],
role="assistant",
content=result.content or None,
tool_calls=[{
"id": tc["id"], "type": "function",
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
} for tc in result.tool_calls],
))
continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply
final_reply = (result.content or "").strip()
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
messages.append(ProxyMessage(
role="tool",
tool_call_id=tc["id"],
name=tc["name"],
content=tool_result[:8000],
))
continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply
final_reply = (result.content or "").strip()
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy")
if not final_reply:
raise RuntimeError("Leerer Reply vom Proxy")
except Exception as exc:
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
# Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
# mit der JSONL-Persistenz aufmachen).
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
self.conversation.add("assistant", err_text)
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply)
@@ -496,6 +992,7 @@ class Agent:
readme=arguments.get("readme", ""),
args=arguments.get("args", []),
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -510,6 +1007,35 @@ class Agent:
},
})
return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
if name == "skill_scaffold":
skill_name = (arguments.get("name") or "").strip()
template = (arguments.get("template") or "").strip()
params = arguments.get("params") or {}
if not skill_name or not template:
return "FEHLER: name + template erforderlich."
try:
manifest = skills_mod.scaffold_skill(
name=skill_name, template=template, params=params, author="aria",
)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event analog zu skill_create
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": manifest["name"],
"description": manifest.get("description", ""),
"execution": manifest.get("execution", ""),
"active": manifest.get("active", True),
"setup_error": manifest.get("setup_error"),
"scaffolded_from": template,
},
})
return (
f"OK — Skill '{manifest['name']}' aus Template '{template}' angelegt. "
f"active={manifest['active']}. "
f"Falls noetig: skill_update fuer custom Code, skill_set_config fuer secrets."
)
if name == "skill_list":
items = skills_mod.list_skills(active_only=False)
if not items:
@@ -518,15 +1044,128 @@ class Agent:
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
for s in items
)
if name == "skill_update":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
return "FEHLER: name ist Pflicht."
patch: dict = {}
for k in ("entry_code", "readme", "description", "args", "active"):
if k in arguments and arguments[k] is not None:
patch[k] = arguments[k]
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
patch["config_schema"] = arguments["config_schema"]
if not patch:
return "FEHLER: keine Felder zum Update angegeben."
try:
manifest = skills_mod.update_skill(skill_name, patch)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event als skill_created getarnt — gleiche Bubble-Mechanik
# in App/Diagnostic; das Update soll fuer Stefan ebenfalls sichtbar werden.
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": manifest["name"],
"description": manifest.get("description", ""),
"execution": manifest.get("execution", ""),
"active": manifest.get("active", True),
"setup_error": manifest.get("setup_error"),
"updated": True,
},
})
changed = ", ".join(sorted(patch.keys()))
return f"OK — Skill '{skill_name}' aktualisiert ({changed}). active={manifest['active']}"
if name == "skill_delete":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
return "FEHLER: name ist Pflicht."
try:
skills_mod.delete_skill(skill_name)
except ValueError as exc:
return f"FEHLER: {exc}"
return f"OK — Skill '{skill_name}' geloescht."
if name == "skill_set_config":
skill_name = (arguments.get("name") or "").strip()
values = arguments.get("values")
if not skill_name or not isinstance(values, dict):
return "FEHLER: name + values (dict) erforderlich."
try:
skills_mod.set_skill_config(skill_name, values)
except ValueError as exc:
return f"FEHLER: {exc}"
masked = skills_mod.get_skill_config_masked(skill_name)
return (
f"OK — Config fuer Skill '{skill_name}' gesetzt. "
f"Aktuelle Werte (secrets gemasked): {masked}"
)
if name == "skill_list_versions":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
return "FEHLER: name ist Pflicht."
versions = skills_mod.list_skill_versions(skill_name)
if not versions:
return f"Skill '{skill_name}' hat keine archivierten Versionen."
lines = [
f"- {v.get('version_id')} ({v.get('archived_at','?')}) {v.get('summary','')}"
for v in versions
]
return "Versionen (neueste zuerst):\n" + "\n".join(lines)
if name == "skill_rollback":
skill_name = (arguments.get("name") or "").strip()
version_id = (arguments.get("version_id") or "").strip()
if not skill_name or not version_id:
return "FEHLER: name + version_id erforderlich."
try:
res = skills_mod.rollback_skill(skill_name, version_id)
except ValueError as exc:
return f"FEHLER: {exc}"
# Side-Channel-Event als skill_created getarnt — App/Diagnostic
# zeigen Rollback dann als sichtbare Aktion an
self._pending_events.append({
"type": "skill_created",
"skill": {
"name": skill_name,
"description": "(rollback)",
"execution": "local-venv",
"active": True,
"updated": True,
},
})
return (
f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
)
if name.startswith("run_"):
skill_name = name[len("run_"):]
# Tool-Namen sind 'safe' (nur _), Skill-Namen koennen aber
# Bindestriche enthalten (z.B. yt-dlp-download). Wir suchen
# zuerst exakt, dann ueber Underscore-zu-Bindestrich-Mapping.
tool_suffix = name[len("run_"):]
skill_name = tool_suffix
if skills_mod.read_manifest(skill_name) is None:
# ggf. Bindestriche zurueckmappen
for cand in skills_mod.list_skills(active_only=False):
cand_name = cand.get("name") or ""
if re.sub(r"[^a-zA-Z0-9_]", "_", cand_name) == tool_suffix:
skill_name = cand_name
break
res = skills_mod.run_skill(skill_name, args=arguments)
snippet = (res.get("stdout") or "")[:2000] or "(kein stdout)"
err = (res.get("stderr") or "")[:500]
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte
# KB Context, 50 KB pro Tool-Result sind locker drin.
stdout = (res.get("stdout") or "")
stderr = (res.get("stderr") or "")
if len(stdout) > 50000:
stdout = stdout[:50000] + f"\n...(abgeschnitten, original {len(res.get('stdout',''))} bytes)"
if len(stderr) > 4000:
stderr = stderr[:4000] + f"\n...(abgeschnitten)"
snippet = stdout or "(kein stdout)"
marker = "OK" if res["ok"] else f"FEHLER (exit={res['exit_code']})"
out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}"
if err:
out += f"\nstderr:\n{err}"
if stderr:
out += f"\nstderr:\n{stderr}"
return out
if name == "trigger_timer":
fires_at_iso = arguments.get("fires_at")
@@ -598,6 +1237,143 @@ class Agent:
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
if name == "oauth_register_provider":
svc = (arguments.get("service") or "").strip()
auth_url = (arguments.get("auth_url") or "").strip()
token_url = (arguments.get("token_url") or "").strip()
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
client_auth = (arguments.get("client_auth") or "body").strip().lower()
if not svc or not auth_url or not token_url:
return "FEHLER: service, auth_url, token_url sind Pflicht."
try:
entry = oauth_mod.register_provider(
svc, auth_url, token_url, scopes=scopes, client_auth=client_auth,
)
except ValueError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_register_provider fehlgeschlagen")
return f"FEHLER: {exc}"
cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"<RVS_HOST nicht gesetzt>/oauth/callback/{svc}"
scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)"
return (
f"OK — Provider '{svc}' registriert.\n"
f" auth_url: {entry['auth_url']}\n"
f" token_url: {entry['token_url']}\n"
f" scopes: {scopes_str}\n"
f" client_auth: {entry['client_auth']}\n\n"
f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > "
f"Settings > OAuth-Apps) deine client_id + client_secret aus "
f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL "
f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n"
f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf."
)
if name == "oauth_authorize":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht (z.B. 'spotify')."
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
try:
info = oauth_mod.build_authorize_url(svc, scopes=scopes)
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_authorize fehlgeschlagen")
return f"FEHLER: {exc}"
return (
f"OK — Authorize-URL fuer {svc} bereit.\n"
f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
f"{info['url']}\n\n"
f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
f"Redirect-URI eintragen."
)
if name == "oauth_get_token":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
try:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
)
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc:
return "FEHLER: service ist Pflicht."
ok = oauth_mod.revoke(svc)
return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
if name == "flux_generate":
prompt = (arguments.get("prompt") or "").strip()
if not prompt:
return "FEHLER: prompt ist Pflicht."
req: dict = {"prompt": prompt}
for key in ("width", "height", "steps", "seed"):
if key in arguments and arguments[key] is not None:
try:
req[key] = int(arguments[key])
except (TypeError, ValueError):
pass
if arguments.get("guidance_scale") is not None:
try:
req["guidance_scale"] = float(arguments["guidance_scale"])
except (TypeError, ValueError):
pass
# Modell-Wahl: 'default' (oder weglassen) → flux-bridge nimmt Diagnostic-Default.
# 'dev' / 'schnell' → expliziter Override.
model_arg = (arguments.get("model") or "").strip().lower()
if model_arg in ("dev", "schnell"):
req["model"] = model_arg
# `raw` ist Brain-Domain (kein Rewriting des prompt) und wird hier
# nicht durchgereicht — der prompt enthaelt bei raw=true bereits
# Stefans Originaltext.
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/flux-generate", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=FLUX_HTTP_TIMEOUT_SEC) as resp:
raw = resp.read()
result = json.loads(raw.decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
err_body = exc.read().decode("utf-8", "ignore")
err_data = json.loads(err_body)
err = err_data.get("error") or err_body
except Exception:
err = str(exc)
return f"FEHLER (flux-bridge): {err}"
except Exception as exc:
logger.exception("flux_generate HTTP-Call fehlgeschlagen")
return f"FEHLER: flux-bridge nicht erreichbar ({exc})"
if not result.get("ok"):
return f"FEHLER (flux-bridge): {result.get('error', 'unbekannt')}"
# Kompakte Rueckmeldung: Pfad + Render-Stats. Brain bettet den
# Pfad in ihre Antwort als [FILE: ...]-Marker ein (siehe Tool-Beschreibung).
return (
f"OK — Bild generiert.\n"
f"path: {result['path']}\n"
f"size: {result.get('width','?')}x{result.get('height','?')} "
f"({result.get('sizeBytes',0)//1024} KB)\n"
f"steps={result.get('steps','?')} guidance={result.get('guidance','?')} "
f"seed={result.get('seed','?')} model={result.get('model','?')}\n"
f"renderSeconds={result.get('renderSeconds','?')}\n\n"
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
)
if name == "memory_search":
query = (arguments.get("query") or "").strip()
if not query:
+68 -19
View File
@@ -27,7 +27,12 @@ import watcher as watcher_mod
logger = logging.getLogger(__name__)
TICK_SEC = 30
# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
TICK_SEC = 8
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
near()-State-Tracking: entered_near/left_near brauchen die Information
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
Wir halten das pro Trigger als near_states-Dict im Manifest und
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
try:
all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e:
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
if not all_triggers:
return
now = datetime.now(timezone.utc)
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
try:
vars_ = watcher_mod.collect_variables()
except Exception as e:
logger.warning("collect_variables: %s", e)
vars_ = {}
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
# damit der Check-Interval respektiert wird)
for t in all_triggers:
if t.get("type") == "watcher":
try:
t["last_checked_at"] = _now_iso()
triggers_mod.write(t["name"], t)
except Exception:
pass
for trigger in all_triggers:
if trigger.get("type") != "watcher":
continue
try:
if _should_fire(trigger, vars_, now):
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
prev = trigger.get("near_states") or {}
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
fired = _should_fire(trigger, vars_, now)
# State immer updaten, egal ob gefeuert wurde — sonst greift
# entered_near/left_near nicht
new_states = vars_.get("_new_near_states") or {}
trigger["near_states"] = new_states
trigger["last_checked_at"] = _now_iso()
try:
triggers_mod.write(trigger["name"], trigger)
except Exception as e:
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
if fired:
# Feuern als eigener Task — wenn ARIA langsam antwortet,
# darf der naechste Tick nicht blockieren
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
# Timer (one-shot) — separat ohne near-State
timer_vars = None
for trigger in all_triggers:
if trigger.get("type") != "timer":
continue
try:
if timer_vars is None:
timer_vars = watcher_mod.collect_variables()
if _should_fire(trigger, timer_vars, now):
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
# Lifespan-Pfad geschleust zu werden.
_AGENT_FACTORY = None
async def tick_now() -> dict:
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
_persist_location, damit Watcher mit near() den frischen Wert sofort
sehen statt bis zu TICK_SEC Sekunden zu warten."""
if _AGENT_FACTORY is None:
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
try:
await _tick(_AGENT_FACTORY)
return {"ok": True}
except Exception as exc:
logger.exception("tick_now: %s", exc)
return {"ok": False, "error": str(exc)}
async def run_loop(agent_factory) -> None:
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
global _AGENT_FACTORY
_AGENT_FACTORY = agent_factory
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
while True:
try:
+245 -1
View File
@@ -36,6 +36,8 @@ import metrics as metrics_mod
import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -45,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
Trigger-Background-Loop anwerfen. Beim Shutdown: Loop stoppen."""
try:
result = seed_rules_mod.apply(store(), embedder())
logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
@@ -657,6 +665,16 @@ def triggers_list(active_only: bool = False):
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
@app.post("/triggers/check-now")
async def triggers_check_now():
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
zu warten. Wird von der Bridge nach jedem location_update gerufen
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
return await background_mod.tick_now()
@app.get("/triggers/conditions")
def triggers_conditions():
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
@@ -739,6 +757,7 @@ class SkillCreate(BaseModel):
requires: dict = Field(default_factory=dict)
pip_packages: list = Field(default_factory=list)
author: str = "stefan"
config_schema: list = Field(default_factory=list)
class SkillRun(BaseModel):
@@ -751,6 +770,18 @@ class SkillPatch(BaseModel):
description: str | None = None
active: bool | None = None
args: list | None = None
entry_code: str | None = None
readme: str | None = None
pip_packages: list | None = None
config_schema: list | None = None
class SkillConfigSet(BaseModel):
values: dict
class SkillRollback(BaseModel):
version_id: str
@app.get("/skills/list")
@@ -767,6 +798,32 @@ def skills_get(name: str):
return {"manifest": m, "readme": readme}
class SkillScaffold(BaseModel):
name: str
template: str # oauth-api | apikey-api | file-process
params: dict = Field(default_factory=dict)
author: str = "stefan"
@app.get("/skills/templates")
def skills_templates_list():
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
import skill_templates as st
return {"templates": st.list_templates()}
@app.post("/skills/scaffold")
def skills_scaffold(body: SkillScaffold):
"""Baut einen Skill aus einem Template (oauth-api / apikey-api / file-process)."""
try:
return skills_mod.scaffold_skill(
name=body.name, template=body.template,
params=body.params, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/skills/create")
def skills_create(body: SkillCreate):
try:
@@ -780,6 +837,7 @@ def skills_create(body: SkillCreate):
requires=body.requires,
pip_packages=body.pip_packages,
author=body.author,
config_schema=body.config_schema,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@@ -816,6 +874,57 @@ def skills_logs(name: str, limit: int = 50):
return {"logs": skills_mod.list_logs(name, limit=limit)}
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
@app.get("/skills/{name}/config")
def skills_config_get(name: str):
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
'***SET***')."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {
"schema": manifest.get("config_schema") or [],
"values": skills_mod.get_skill_config_masked(name),
}
@app.post("/skills/{name}/config")
def skills_config_set(name: str, body: SkillConfigSet):
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
manifest = skills_mod.read_manifest(name)
if manifest is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
skills_mod.set_skill_config(name, body.values)
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
# ── Skill-Versions (P4): rollback ──────────────────────────────────
@app.get("/skills/{name}/versions")
def skills_versions_list(name: str):
if skills_mod.read_manifest(name) is None:
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
return {"versions": skills_mod.list_skill_versions(name)}
@app.post("/skills/{name}/rollback")
def skills_rollback(name: str, body: SkillRollback):
try:
return skills_mod.rollback_skill(name, body.version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/skills/{name}/versions/{version_id}")
def skills_versions_delete(name: str, version_id: str):
try:
return skills_mod.delete_skill_version(name, version_id)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.get("/skills/{name}/export")
def skills_export(name: str):
try:
@@ -839,3 +948,138 @@ async def skills_import(request: Request, overwrite: bool = False):
except ValueError as exc:
raise HTTPException(400, str(exc))
return {"imported": manifest}
# ── OAuth ─────────────────────────────────────────────────────────
@app.get("/oauth/services")
async def oauth_services_list():
"""Liste aller Services mit Status (configured/authenticated/expires)."""
return {"services": oauth_mod.list_services()}
@app.get("/oauth/apps")
async def oauth_apps_get():
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
safe = {}
for service, entry in apps.items():
if not isinstance(entry, dict):
continue
safe[service] = {
"client_id": entry.get("client_id", ""),
"has_client_secret": bool(entry.get("client_secret")),
"scopes": entry.get("scopes"),
"auth_url": entry.get("auth_url"),
"token_url": entry.get("token_url"),
}
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
class OAuthAppIn(BaseModel):
service: str
client_id: str = ""
client_secret: str = ""
scopes: Optional[List[str]] = None
auth_url: Optional[str] = None
token_url: Optional[str] = None
@app.post("/oauth/apps")
async def oauth_apps_set(body: OAuthAppIn):
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
kann fuer reine scope-Aenderungen)."""
service = (body.service or "").strip()
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
entry = apps.get(service) or {}
if body.client_id:
entry["client_id"] = body.client_id.strip()
if body.client_secret:
entry["client_secret"] = body.client_secret.strip()
if body.scopes is not None:
entry["scopes"] = body.scopes
if body.auth_url:
entry["auth_url"] = body.auth_url.strip()
if body.token_url:
entry["token_url"] = body.token_url.strip()
apps[service] = entry
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
return {"ok": True, "service": service}
@app.delete("/oauth/apps/{service}")
async def oauth_apps_delete(service: str):
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
if service in apps:
apps.pop(service)
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
# Token auch wegwerfen
oauth_mod.revoke(service)
return {"ok": True}
@app.post("/oauth/{service}/revoke")
async def oauth_revoke_endpoint(service: str):
return {"ok": oauth_mod.revoke(service)}
@app.get("/oauth/{service}/token")
async def oauth_token_endpoint(service: str):
"""Liefert das aktuelle access_token fuer einen Service (mit Auto-Refresh
wenn < 60s Restzeit). Nur fuer interne Skill-Aufrufe gedacht — Skills
sollen NIEMALS hardcoded client_secrets haben, sondern dieses Endpoint
pollen. Antwort: {access_token, expires_at, expires_in_sec}.
Bei nicht-autorisiert: 401 mit klarer Message."""
try:
rec = oauth_mod.get_token(service)
except RuntimeError as exc:
raise HTTPException(401, str(exc))
expires_at = int(rec.get("expires_at") or 0)
import time as _t
return {
"access_token": rec.get("access_token"),
"expires_at": expires_at,
"expires_in_sec": max(0, expires_at - int(_t.time())),
}
class OAuthAuthorizeIn(BaseModel):
service: str
scopes: Optional[List[str]] = None
@app.post("/oauth/authorize")
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
try:
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
except RuntimeError as exc:
raise HTTPException(400, str(exc))
@app.post("/internal/oauth-callback")
async def oauth_callback_internal(request: Request):
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
Macht den state-Match + token-exchange und persistiert."""
try:
body = await request.json()
except Exception as exc:
raise HTTPException(400, f"bad json: {exc}")
service = (body.get("service") or "").strip()
code = (body.get("code") or "").strip()
state = (body.get("state") or "").strip()
err = body.get("error") or None
err_desc = body.get("errorDescription") or None
if not service:
raise HTTPException(400, "service erforderlich")
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
return result
+441
View File
@@ -0,0 +1,441 @@
"""
OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer
Spotify, Google, GitHub, Strava, Microsoft etc.
Architektur:
- Brain haelt einen Pending-Store: state-String → pending Auth-Request
(mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS),
matched der state und der code wird gegen access_token getauscht.
- Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag
mit access_token, refresh_token, expires_at, scope).
- Provider-Configs: /shared/config/oauth_apps.json — pro Service
{client_id, client_secret, auth_url, token_url, scopes, ...}. Wird
typischerweise via Diagnostic-UI gefuellt.
- Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s
bis Ablauf bei get_token() Aufruf.
OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service}
RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy).
ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro
Service im Provider-Dashboard registrieren.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import secrets
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
CONFIG_DIR = Path("/shared/config")
APPS_FILE = CONFIG_DIR / "oauth_apps.json"
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"
# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
# uebersteuert). Aktuell nur Spotify als out-of-the-box Service — fuer alles
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
# danach nur client_id + client_secret in Diagnostic / App eintragen.
DEFAULT_PROVIDERS: dict[str, dict] = {
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"scopes": ["user-read-playback-state", "user-modify-playback-state",
"user-read-currently-playing", "playlist-read-private",
"user-library-read"],
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
},
}
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
_PENDING: dict[str, dict] = {}
PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig
# ── Helpers ─────────────────────────────────────────────────
def _callback_url(service: str) -> str:
"""Baut die Redirect-URL die wir bei der Provider-Auth angeben.
Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env."""
host = os.environ.get("RVS_HOST", "").strip()
if not host:
raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich")
port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
scheme = "https" if tls else "http"
# Default-Ports 443/80 nicht in URL anhaengen
if (tls and port == "443") or (not tls and port == "80"):
return f"{scheme}://{host}/oauth/callback/{service}"
return f"{scheme}://{host}:{port}/oauth/callback/{service}"
def _load_json(path: Path) -> dict:
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc)
return {}
def _save_json(path: Path, data: dict) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
# 600 — enthaelt Secrets
try: os.chmod(path, 0o600)
except OSError: pass
except Exception as exc:
logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc)
def _provider_config(service: str) -> dict:
"""Mergt Default-Provider-Config mit User-Override aus oauth_apps.json."""
defaults = DEFAULT_PROVIDERS.get(service, {}).copy()
apps = _load_json(APPS_FILE)
user = (apps.get(service) or {}).copy()
# Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list.
merged = {**defaults, **user}
return merged
def _provider_credentials(service: str) -> tuple[str, str]:
"""Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht
konfiguriert — der OAuth-Flow kann ohne nicht starten."""
apps = _load_json(APPS_FILE)
entry = apps.get(service) or {}
cid = (entry.get("client_id") or "").strip()
sec = (entry.get("client_secret") or "").strip()
if not cid or not sec:
raise RuntimeError(
f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > "
f"OAuth-Apps client_id + client_secret eintragen."
)
return cid, sec
def register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None,
client_auth: str = "body",
extra_auth_params: Optional[dict] = None,
accept_header: Optional[str] = None) -> dict:
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen.
Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
aber vorhandene client_id/client_secret bleiben unberuehrt.
"""
svc = (service or "").strip()
if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
raise ValueError(f"Ungueltiger service-Name: {service!r}")
if not auth_url.startswith(("http://", "https://")):
raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
if not token_url.startswith(("http://", "https://")):
raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
if client_auth not in ("body", "basic"):
raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")
apps = _load_json(APPS_FILE)
entry = apps.get(svc) or {}
entry["auth_url"] = auth_url.strip()
entry["token_url"] = token_url.strip()
if scopes is not None:
entry["scopes"] = list(scopes)
entry["client_auth"] = client_auth
if extra_auth_params is not None:
entry["extra_auth_params"] = extra_auth_params
if accept_header is not None:
entry["accept_header"] = accept_header
apps[svc] = entry
_save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
svc, auth_url, token_url, len(entry.get("scopes") or []))
return entry
def _cleanup_pending() -> None:
"""Entfernt abgelaufene Pending-Auths."""
now = time.time()
for state, info in list(_PENDING.items()):
if now - info.get("created_at", 0) > PENDING_TTL_SEC:
_PENDING.pop(state, None)
# ── Authorize ───────────────────────────────────────────────
def build_authorize_url(service: str, scopes: Optional[list[str]] = None,
extra_params: Optional[dict] = None) -> dict:
"""Baut die Authorize-URL fuer einen Provider. Speichert den state
im Pending-Store. Returns {url, state, redirect_uri, service}.
Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan,
der oeffnet sie im Browser, autorisiert, Provider redirected zur
redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched
state → exchange.
"""
_cleanup_pending()
cfg = _provider_config(service)
if not cfg.get("auth_url") or not cfg.get("token_url"):
raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. "
f"In oauth_apps.json eintragen oder einen der "
f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).")
cid, _ = _provider_credentials(service)
redirect_uri = _callback_url(service)
state = secrets.token_urlsafe(32)
use_scopes = scopes if scopes else cfg.get("scopes") or []
params = {
"client_id": cid,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
}
if use_scopes:
params["scope"] = " ".join(use_scopes)
params.update(cfg.get("extra_auth_params") or {})
if extra_params:
params.update(extra_params)
url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params)
_PENDING[state] = {
"service": service,
"redirect_uri": redirect_uri,
"scopes": use_scopes,
"created_at": time.time(),
}
logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s",
service, state[:8] + "...", redirect_uri)
return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service}
# ── Token-Exchange ──────────────────────────────────────────
def _token_request(token_url: str, body_params: dict, cfg: dict,
client_id: str, client_secret: str) -> dict:
"""POST an provider /token endpoint. Returns parsed JSON oder wirft."""
data = urllib.parse.urlencode(body_params).encode("utf-8")
headers = {"Content-Type": "application/x-www-form-urlencoded"}
if cfg.get("accept_header"):
headers["Accept"] = cfg["accept_header"]
# Client-Auth: 'basic' (Header) oder 'body' (im Form-Body)
if cfg.get("client_auth") == "basic":
auth_str = f"{client_id}:{client_secret}"
b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {b64}"
else:
# bereits im body_params drin (siehe Caller)
pass
req = urllib.request.Request(token_url, data=data, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", "ignore")
try:
return json.loads(raw)
except json.JSONDecodeError:
# GitHub default ist form-urlencoded — accept_header sollte
# JSON anfordern, aber falls's doch mal kommt:
parsed = urllib.parse.parse_qs(raw)
return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:500]
raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e
def handle_callback(service: str, code: str, state: str,
error: Optional[str] = None,
error_description: Optional[str] = None) -> dict:
"""Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen
Token, speichert. Returns {ok, service, message, ...}.
Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen.
"""
_cleanup_pending()
if error:
# Provider hat User-Abbruch oder Fehler gemeldet
_PENDING.pop(state, None) if state else None
logger.warning("[oauth] Provider-Error %s/%s: %s — %s",
service, state[:8] + "..." if state else "?", error, error_description)
return {"ok": False, "service": service, "error": error,
"errorDescription": error_description}
pending = _PENDING.pop(state, None)
if not pending:
logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service)
return {"ok": False, "service": service,
"error": "invalid_state",
"errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."}
if pending.get("service") != service:
logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s",
pending.get("service"), service)
return {"ok": False, "service": service,
"error": "service_mismatch",
"errorDescription": "state gehoert zu einem anderen Service."}
if not code:
return {"ok": False, "service": service, "error": "no_code"}
cfg = _provider_config(service)
try:
client_id, client_secret = _provider_credentials(service)
except RuntimeError as exc:
return {"ok": False, "service": service, "error": "no_credentials",
"errorDescription": str(exc)}
body = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": pending["redirect_uri"],
}
if cfg.get("client_auth") != "basic":
body["client_id"] = client_id
body["client_secret"] = client_secret
try:
token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
except Exception as exc:
logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service)
return {"ok": False, "service": service, "error": "exchange_failed",
"errorDescription": str(exc)[:200]}
access = token_data.get("access_token")
if not access:
return {"ok": False, "service": service, "error": "no_access_token",
"errorDescription": str(token_data)[:200]}
expires_in = int(token_data.get("expires_in") or 3600)
refresh = token_data.get("refresh_token") or ""
scope = token_data.get("scope") or " ".join(pending.get("scopes") or [])
token_type = token_data.get("token_type") or "Bearer"
record = {
"service": service,
"access_token": access,
"refresh_token": refresh,
"token_type": token_type,
"scope": scope,
"expires_at": int(time.time()) + expires_in,
"obtained_at": int(time.time()),
}
_persist_token(service, record)
logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s",
service, expires_in, "ja" if refresh else "nein")
return {"ok": True, "service": service, "expiresIn": expires_in,
"hasRefresh": bool(refresh), "scope": scope}
# ── Token-Storage / Refresh / Revoke ─────────────────────────
def _persist_token(service: str, record: dict) -> None:
tokens = _load_json(TOKENS_FILE)
tokens[service] = record
_save_json(TOKENS_FILE, tokens)
def _load_token(service: str) -> Optional[dict]:
return _load_json(TOKENS_FILE).get(service)
def get_token(service: str, refresh_threshold_sec: int = 60) -> dict:
"""Holt das aktuelle access_token fuer einen Service. Refresht automatisch
wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze
record-dict — Caller nimmt sich access_token raus.
Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer
soll dann oauth_authorize anbieten."""
record = _load_token(service)
if not record:
raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per "
f"oauth_authorize authentifizieren.")
exp = int(record.get("expires_at") or 0)
remaining = exp - int(time.time())
if remaining > refresh_threshold_sec:
return record
# Refresh noetig
refresh_tok = (record.get("refresh_token") or "").strip()
if not refresh_tok:
raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token "
f"vorhanden — bitte neu autorisieren mit oauth_authorize.")
cfg = _provider_config(service)
client_id, client_secret = _provider_credentials(service)
body = {
"grant_type": "refresh_token",
"refresh_token": refresh_tok,
}
if cfg.get("client_auth") != "basic":
body["client_id"] = client_id
body["client_secret"] = client_secret
try:
new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
except Exception as exc:
raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc
new_access = new_data.get("access_token")
if not new_access:
raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}")
expires_in = int(new_data.get("expires_in") or 3600)
# refresh_token kann (manche Provider) bei jedem Refresh rotieren
new_refresh = (new_data.get("refresh_token") or refresh_tok).strip()
record.update({
"access_token": new_access,
"refresh_token": new_refresh,
"expires_at": int(time.time()) + expires_in,
"obtained_at": int(time.time()),
})
if new_data.get("scope"):
record["scope"] = new_data["scope"]
_persist_token(service, record)
logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in)
return record
def revoke(service: str) -> bool:
"""Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call)."""
tokens = _load_json(TOKENS_FILE)
if service not in tokens:
return False
tokens.pop(service, None)
_save_json(TOKENS_FILE, tokens)
logger.info("[oauth] %s Token geloescht (lokal).", service)
return True
def list_services() -> list[dict]:
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
+ Ablaufzeit. Wird von Diagnostic genutzt."""
apps = _load_json(APPS_FILE)
tokens = _load_json(TOKENS_FILE)
out = []
services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys())
now = int(time.time())
for s in sorted(services):
app = apps.get(s) or {}
tok = tokens.get(s) or {}
configured = bool(app.get("client_id") and app.get("client_secret"))
out.append({
"service": s,
"configured": configured,
"authenticated": bool(tok.get("access_token")),
"expiresAt": tok.get("expires_at"),
"expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None,
"hasRefresh": bool(tok.get("refresh_token")),
"scope": tok.get("scope", ""),
"isDefault": s in DEFAULT_PROVIDERS,
})
return out
+117 -11
View File
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
"Stefan fragen ob es ins Brain-Dockerfile soll.")
lines.append("")
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
"Container-Restart wiederholt.")
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
"macht ARIA stumm und nervt Stefan jedes Mal.")
lines.append("")
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
lines.append("")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
lines.append("")
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
"'bau daraus einen Skill'.")
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
return "\n".join(lines)
@@ -239,6 +240,94 @@ def build_triggers_section(
return "\n".join(lines)
def build_oauth_section(oauth_services: list[dict] | None,
callback_host: str = "",
callback_port: str = "443",
callback_tls: bool = True) -> str:
"""Block fuer den System-Prompt: zeigt ARIA welche externen Services
via OAuth verfuegbar sind, welche schon authentifiziert sind, und welche
Callback-URL beim Provider eingetragen werden muss."""
scheme = "https" if callback_tls else "http"
if callback_host:
if (callback_tls and callback_port == "443") or (not callback_tls and callback_port == "80"):
base = f"{scheme}://{callback_host}/oauth/callback/<SERVICE>"
else:
base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/<SERVICE>"
else:
base = "<nicht konfiguriert — RVS_HOST in brain env fehlt>"
lines = [
"## OAuth externe Services",
"",
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
"",
f"**Callback-URL (fest, NICHT raten):** `{base}`",
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
"Redirect-URI eintragen. Sie ist hardcoded an die RVS-Infrastruktur gebunden "
"und aendert sich nicht — auch nicht wenn Du als Brain neu aufgesetzt wirst.",
"",
"**NICHT** versuchen client_id / client_secret selbst zu generieren oder zu "
"raten. Wenn nicht eingetragen → Stefan sagen er soll es in Diagnostic > "
"OAuth-Apps machen.",
]
if oauth_services:
lines.append("")
lines.append("**Aktuelle Service-Status:**")
for s in oauth_services:
name = s.get("service", "?")
configured = s.get("configured", False)
auth = s.get("authenticated", False)
remain = s.get("expiresInSec")
parts = []
if not configured:
parts.append("Credentials fehlen")
elif not auth:
parts.append("nicht authentifiziert")
else:
if remain is None:
parts.append("authentifiziert")
elif remain > 0:
parts.append(f"authentifiziert, Token gueltig noch {remain}s")
else:
parts.append("Token abgelaufen (wird automatisch refresht)")
lines.append(f"- `{name}`: {' / '.join(parts)}")
return "\n".join(lines)
def build_flux_section(flux_config: dict) -> str:
"""Block fuer den System-Prompt: aktuelle Diagnostic-Settings fuer
Bildgenerierung (Default-Modell + User-konfigurierbare Keywords).
flux_config kommt aus /shared/config/voice_config.json:
fluxDefaultModel: "dev" | "schnell" (Default "dev")
fluxKeywordRaw: z.B. "flux" (Pipe-Modus, kein Rewriting)
fluxKeywordSwitch:z.B. "fix" (anderes Modell als Default)
"""
default_model = (flux_config or {}).get("fluxDefaultModel", "dev")
kw_raw = (flux_config or {}).get("fluxKeywordRaw", "flux")
kw_switch = (flux_config or {}).get("fluxKeywordSwitch", "fix")
other_model = "schnell" if default_model == "dev" else "dev"
lines = [
"## FLUX Bildgenerierung",
f"- Default-Modell: `{default_model}` (alternativ: `{other_model}`).",
f"- Raw-Keyword: `{kw_raw}` — wenn Stefans Nachricht damit beginnt "
f"oder das Wort als ersten echten Wortteil enthaelt, ruf "
f"`flux_generate(..., raw=true)` und leite seinen Text 1:1 als prompt "
f"durch. KEIN Uebersetzen, KEIN Beautify, KEINE Stil-Adds.",
f"- Switch-Keyword: `{kw_switch}` — taucht's in der Nachricht auf, "
f"setze `model=\"{other_model}\"` (das ANDERE Modell als das Default).",
"- Natuerliche Sprache funktioniert auch: 'mal eben fix' / 'schnell' → schnell, "
"'in hoher Qualitaet' / 'detailliert' → dev.",
"- Whisper-Erkennung des Raw-Keywords ist nicht perfekt — wenn Stefans "
"Sprachnachricht z.B. mit 'fluks', 'flocks', 'fluxx' anfaengt, behandle "
"das auch als Raw-Keyword.",
]
return "\n".join(lines)
def build_system_prompt(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
@@ -246,8 +335,13 @@ def build_system_prompt(
triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None,
condition_funcs: List[dict] | None = None,
flux_config: dict | None = None,
oauth_services: list[dict] | None = None,
oauth_callback_host: str = "",
oauth_callback_port: str = "443",
oauth_callback_tls: bool = True,
) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
parts = [build_hot_memory_section(pinned), "", build_time_section()]
if skills:
parts.append("")
@@ -255,6 +349,18 @@ def build_system_prompt(
if condition_vars:
parts.append("")
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
if flux_config is not None:
parts.append("")
parts.append(build_flux_section(flux_config))
# OAuth-Block bauen wir nur wenn RVS_HOST konfiguriert ist (sonst hat
# die Callback-URL keinen Sinn). Sonst lassen wir den Block weg statt
# ARIA eine "<nicht konfiguriert>"-URL zu zeigen.
if oauth_callback_host:
parts.append("")
parts.append(build_oauth_section(oauth_services,
callback_host=oauth_callback_host,
callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+20 -3
View File
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
# Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
def _read_model_from_runtime() -> str:
@@ -62,8 +72,15 @@ class ProxyClient:
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
self.base_url = base_url.rstrip("/")
self.model = model
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC)
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
# Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
# ReadTimeout), read gross (ARIA darf ewig rechnen).
self._client = httpx.Client(timeout=httpx.Timeout(
connect=PROXY_CONNECT_TIMEOUT_SEC,
read=PROXY_TIMEOUT_SEC,
write=PROXY_WRITE_TIMEOUT_SEC,
pool=PROXY_POOL_TIMEOUT_SEC,
))
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
+659
View File
@@ -0,0 +1,659 @@
"""
System-Seed-Regeln — werden bei jedem Brain-Boot idempotent in die
Vector-DB geschrieben (pinned, source="seed").
Im Gegensatz zu aria-data/brain-import/ (User-Saatgut, manuell via
Diagnostic-Klick migriert) ist das hier System-Regeln, die zum Brain-Code
gehoeren und mit jedem Deploy ausgerollt werden.
Idempotenz: Punkte mit gleicher `migration_key` werden vor dem Schreiben
geloescht. Editieren = Zeile aendern, Brain neu starten, fertig.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import List
from memory import Embedder, VectorStore
from memory.vector_store import COLLECTION
from qdrant_client.http import models as qm
logger = logging.getLogger(__name__)
# Jede Regel = ein eigener Memory-Punkt. Klein halten, klar formulieren —
# ARIA sieht das in jedem Chat-Turn als pinned Hot Memory.
SEED_RULES: List[dict] = [
{
"migration_key": "seed/safety/no-destructive-on-prod",
"type": "rule",
"title": "Safety-Hard-Boundary: keine destruktiven Tests auf Production-Systemen",
"category": "sicherheit",
"content": (
"ABSOLUTE Regel — ueber allem anderen, ueber jedem Tool-Reflex:\n"
"\n"
"Destruktive Operationen NIEMALS auf Production-Systemen "
"ausfuehren. Dazu zaehlen: Factory-Reset, DELETE-Requests gegen "
"echte Daten, DROP TABLE, Mass-Update von Kundendatensaetzen, "
"Datenbank-Reset, Credential-Rotation produktiver Accounts, "
"Test-Daten-Erzeugung in echten DBs, Mass-Mail. Auch nicht "
"'nur kurz zum Testen'. Auch nicht 'mit Backup koennen wir's "
"rueckgaengig machen'.\n"
"\n"
"Bei Pentest, Audit, Refactoring-Test oder aehnlichem:\n"
" 1. SOFORT pruefen ob ein dediziertes Staging/Test-System "
"existiert. Hinweise im Hostnamen: 'stage', 'staging', 'test', "
"'dev', 'qa'. URL muss explizit als Test-Umgebung markiert sein.\n"
" 2. Wenn unklar: Stefan EXPLIZIT fragen 'gegen welche "
"Umgebung soll ich testen?'. Lieber 5 Sekunden Wartezeit als "
"ein unwiderrufliches Daten-Disaster.\n"
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
"\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
"dessen Staging-Umgebung (Staging). Stefan "
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"\n"
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
"Anweisung. Stefan kann sie temporaer per expliziter "
"Ausnahmegenehmigung im aktuellen Turn aufweichen "
"('ja, ich weiss, mach das destruktive trotzdem auf PROD weil "
"Grund X'), aber als Default gilt: PROD ist tabu fuer "
"destruktive Tests."
),
},
{
"migration_key": "seed/skill-rule/list-before-create",
"type": "rule",
"title": "Skill-Regel: skill_list vor skill_create",
"category": "skills",
"content": (
"Bevor du einen neuen Skill mit `skill_create` anlegst, ruf IMMER "
"zuerst `skill_list` auf. Schau dir die Namen und Descriptions an. "
"Wenn ein passender Skill existiert: verwende ihn oder verbessere "
"ihn mit `skill_update`. Lege keinen Duplikat-Skill an."
),
},
{
"migration_key": "seed/skill-rule/snake-case-names",
"type": "rule",
"title": "Skill-Regel: Skill-Namen nur snake_case (keine Bindestriche)",
"category": "skills",
"content": (
"Skill-Namen MUESSEN snake_case sein — nur a-z, 0-9 und _ "
"(Underscore). KEINE Bindestriche.\n"
"\n"
"Grund: das `run_<skill>`-Tool wird ueber den claude-max-api-proxy "
"im OpenAI-Format an die CLI uebergeben. Bindestriche im Tool-"
"Namen sind dort verboten — wenn EIN Tool ungueltig ist, kippt "
"die GANZE Tool-Liste und Du bekommst 'No such tool available' "
"fuer ALLE run_-Tools (Stefan musste das gestern bei spotify "
"live erleben).\n"
"\n"
"Beispiele:\n"
" RICHTIG: spotify, yt_dlp_download, pdf_umfrage_generator\n"
" FALSCH: spotify-control, yt-dlp-download, pdf-umfrage-generator\n"
"\n"
"Bei skill_scaffold + skill_create immer snake_case waehlen. "
"Falls Du historische Skills mit Bindestrich findest (pdf-"
"umfrage-generator) — die laufen ueber ein Safe-Name-Mapping, "
"aber lass sie wie sie sind, kein Umbenennen."
),
},
{
"migration_key": "seed/skill-rule/no-version-suffix",
"type": "rule",
"title": "Skill-Regel: keine Versions-Suffixe im Namen",
"category": "skills",
"content": (
"Skill-Namen muessen permanent und beschreibend sein. NIEMALS "
"Suffixe wie `-v2`, `_v3`, `-new`, `-fixed`, `-aria`, `-ctl` "
"anhaengen, um eine neue Variante zu bauen. Wenn ein Skill kaputt "
"ist oder verbessert werden soll: `skill_update`. Versionsverwaltung "
"macht das System intern (Rollback ueber `skill_rollback`)."
),
},
{
"migration_key": "seed/skill-rule/update-not-recreate",
"type": "rule",
"title": "Skill-Regel: kaputten Skill reparieren, nicht neu bauen",
"category": "skills",
"content": (
"Wenn ein vorhandener Skill nicht wie erwartet funktioniert, lies "
"zuerst Code + Logs (`skill_get`, `skill_logs`). Repariere ihn dann "
"mit `skill_update` (entry_code, readme oder pip_packages patchen). "
"Baue NIEMALS einen zweiten Skill mit aehnlichem Namen — das gibt "
"Skill-Friedhof und Stefan muss aufraeumen."
),
},
{
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
"type": "rule",
"title": "Skill-Regel: keine hardcoded Credentials",
"category": "skills",
"content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
"manuell neu einloggen, das nervt."
),
},
{
"migration_key": "seed/skill-rule/config-schema-for-settings",
"type": "rule",
"title": "Skill-Regel: konfigurierbare Werte ueber config_schema",
"category": "skills",
"content": (
"Wenn dein Skill konfigurierbare Werte braucht (User-IDs, "
"Default-Geraete, Endpoints, nicht-OAuth-API-Keys), deklariere "
"sie im `config_schema`-Feld der skill.json. Stefan setzt sie "
"dann in der Diagnostic-UI; der Skill bekommt die Werte zur "
"Laufzeit als Environment-Variable `CFG_<NAME>`. NICHT als "
"Argument, NICHT hardcoded."
),
},
{
"migration_key": "seed/skill-rule/brain-internal-url",
"type": "rule",
"title": "Skill-Regel: BRAIN_INTERNAL_URL ist deine Brain-Schnittstelle",
"category": "skills",
"content": (
"Jeder Skill bekommt die ENV-Variable BRAIN_INTERNAL_URL "
"(Default http://localhost:8080). Damit kann der Skill das Brain "
"aufrufen — kein hardcoden noetig:\n"
" - GET {BRAIN_INTERNAL_URL}/oauth/<service>/token -> access_token "
"(mit Auto-Refresh) fuer jeden OAuth-Service\n"
" - GET {BRAIN_INTERNAL_URL}/memory/search?q=...&k=5 -> "
"Stefans Memories semantisch durchsuchen\n"
" - GET {BRAIN_INTERNAL_URL}/memory/pinned -> Hot Memory (Identitaet, Regeln)\n"
" - GET {BRAIN_INTERNAL_URL}/skills/list -> verfuegbare Skills\n"
"Mehr Endpoints siehe Brain main.py. Lies die URL IMMER aus "
"os.environ['BRAIN_INTERNAL_URL'] — hardcoden waere kaputt sobald "
"der Port wechselt. Beispiel: ein Wetter-Skill kann Stefans "
"Standort per /memory/search holen statt ihn als Arg zu erwarten."
),
},
{
"migration_key": "seed/skill-rule/oauth-reauth-reflex",
"type": "rule",
"title": "Skill-Regel: OAuth-Re-Auth-Reflex (Refresh statt Re-Login)",
"category": "skills",
"content": (
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
"'token expired' zurueckgibt: RUFE ZUERST "
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
"gueltigen Token oder refresht ihn automatisch ueber den "
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
"\n"
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
"('refresh failed', 'no refresh_token', 'service nicht "
"konfiguriert'): DANN `oauth_authorize` und Stefan zum Login "
"schicken. Vorher NIEMALS.\n"
"\n"
"Anti-Pattern (Stefan musste so 3x manuell einloggen weil ich "
"das falsch gemacht hatte): bei jedem 401 reflexartig "
"oauth_authorize zu rufen. Das ist das aergerlichste was Du "
"ihm antun kannst — er muss aus dem Auto raus, Handy "
"rauskramen, klicken. Refresh haendelt das Brain transparent, "
"nutze es."
),
},
{
"migration_key": "seed/skill-rule/no-skill-drift",
"type": "rule",
"title": "Skill-Regel: kein Drift vom Skill zu Ad-hoc-Bash",
"category": "skills",
"content": (
"Wenn ein bestehender Skill ein Problem hat (kaputter Output, "
"fehlender Feature-Wunsch, Setup-Error): lies `skill_logs` und "
"`skill_get`, finde das Problem, fixe es mit `skill_update`. "
"\n"
"ABSOLUT VERBOTEN: 'ich lass den Code jetzt einfach direkt auf "
"der VM laufen' / direkt Bash-curl-Befehle ausfuehren statt "
"den Skill anzufassen. Das macht den Skill zur Karteileiche "
"und beim naechsten Mal hast Du wieder nichts. Stefan kann "
"dann auch nichts wiederverwenden (Triggers, App-UI, Logs).\n"
"\n"
"Auch nicht: 'ich baue dir einen Skill' SAGEN ohne tatsaechlich "
"`skill_create` zu rufen. Stefan checkt die Skill-Liste, und "
"wenn er nichts findet, glaubt er dir nie wieder. Wenn Du es "
"sagst, MACH es. Wenn es Probleme gibt (anti-Friedhof-Check, "
"Setup-Error): sag das ehrlich statt zu halluzinieren."
),
},
{
"migration_key": "seed/skill-rule/no-subagent-for-skills",
"type": "rule",
"title": "Skill-Regel: NIEMALS Sub-Agent fuer run_<skill>-Tools",
"category": "skills",
"content": (
"Wenn Du einen Brain-Skill nutzen willst (run_spotify, "
"run_yt_dlp_download, run_pdf_umfrage_generator, …), rufe das "
"Tool DIREKT in der Haupt-Session auf. NIEMALS via `Agent` / "
"Sub-Agent / Task delegieren.\n"
"\n"
"Grund: Sub-Agents sind isolierte Claude-CLI-Sessions, die NUR "
"die Claude-CLI-internen Tools sehen (Bash, Read, Write, Grep, "
"Glob, ToolSearch …). Brain-Tools (run_*, oauth_*, memory_*, "
"trigger_*, skill_*) sind dort NICHT verfuegbar. Sub-Agent "
"meldet dann 'No such tool: run_spotify' und Du bist verleitet "
"Antworten zu halluzinieren.\n"
"\n"
"Antipattern (Stefan beobachtete das am 30.05.2026): "
"1. User fragt 'welches lied laeuft' → 2. ARIA spawnt `Agent` "
"mit Anweisung 'Call run_spotify…' → 3. Sub-Agent: 'no such "
"tool' → 4. ARIA schreibt einen halluzinierten Track-Namen.\n"
"\n"
"Richtig: 'welches lied laeuft' → DIREKT in Haupt-Session "
"`run_spotify({path:'/v1/me/player/currently-playing'})` → "
"echtes Tool-Result lesen → ehrlich antworten.\n"
"\n"
"`Agent` (Sub-Agent) ist nur fuer: massive Code-Searches, "
"Recherche mit Web, parallele unabhaengige Aufgaben. NICHT "
"fuer eigene Brain-Tools."
),
},
{
"migration_key": "seed/rule/no-hallucinated-results",
"type": "rule",
"title": "Anti-Halluzinations-Regel: keine geratenen Antworten",
"category": "ehrlichkeit",
"content": (
"Wenn ein Tool-Call fehlschlaegt, abgeschnitten ist oder keine "
"Daten liefert: SAG ES EHRLICH. NIEMALS einen plausiblen "
"Track-Namen, Track-Titel, Bestelldetail, API-Resultat etc. "
"RATEN oder aus dem Vorwissen halluzinieren.\n"
"\n"
"HARTE REGEL — Listen-/State-Daten IMMER fetchen, NIE raten:\n"
" - Spotify-Queue / next-up / Playlist-Inhalt\n"
" - Aktueller Track / Wiedergabe-Status / Devices\n"
" - Memory-Liste / Trigger-Liste / Skill-Liste\n"
" - OAuth-Service-Status / API-Quotas\n"
" - Datei-Listen / DB-Inhalte / Stefans GPS\n"
" - Bestellungen, Kalender-Eintraege, Mails, Whatever\n"
"\n"
"Wenn Stefan danach fragt: ZUERST run_<skill> / oauth_get_token / "
"memory_search / trigger_list / etc. aufrufen, das ECHTE Ergebnis "
"zitieren. NICHT auf Training-Wissen oder 'klingt plausibel' "
"zurueckfallen. Eine Sekunde Tool-Call < eine Sekunde Fake-Antwort.\n"
"\n"
"Antipattern-Sammlung (alle 30.05.2026):\n"
" 1. Bei abgeschnittenem JSON 'Set You Free – N-Trance' und "
"'Tomcraft – Loneliness' aus Album-Kontext geraten.\n"
" 2. Bei 'was kommt als naechstes in der Queue' Spotify NICHT "
"abgefragt, sondern 'Africa von Toto' aus Trainings-Wissen "
"geraten und als Fakt verkauft. Stefan hat das gemerkt. "
"Vertrauensbruch.\n"
" 3. Bei 403-Errors 'war schon pausiert' geraten statt den "
"error.reason aus dem Body zu lesen.\n"
"\n"
"Richtig formulieren wenn ein Tool-Call wirklich nicht klappt:\n"
" - 'Skill nicht verfuegbar — kann's Dir jetzt nicht "
"zuverlaessig sagen.'\n"
" - 'Response war abgeschnitten, ich frag nochmal.'\n"
" - 'Das Tool gibt's noch nicht — soll ich's anlegen?'\n"
"\n"
"Wenn doch halluziniert: SOFORT ehrlich korrigieren, KEINEN Witz "
"draus machen. Stefan ist vermutlich angepisst und Humor ist "
"die falsche Reaktion. Erst ernsthaft Vertrauen reparieren, "
"Witze spaeter."
),
},
{
"migration_key": "seed/architecture/runtime-topology",
"type": "rule",
"title": "Architektur: wo Du als ARIA tatsaechlich laufst",
"category": "architektur",
"content": (
"WICHTIG fuer jeden Bash-Reflex: Du bist die `claude` CLI als "
"Subprocess IM `aria-proxy` Container (node:22-alpine). NICHT "
"im aria-brain. Konsequenzen:\n"
"\n"
" - `python3` / `python` / `jq` sind NICHT installiert. Alpine "
"ist minimal. Nutze nur: curl, sed, grep, awk, sh — oder das "
"richtige Tool statt Bash.\n"
" - `/data/skills/` existiert NUR im aria-brain Container. "
"Du kannst Skills NICHT ueber Bash inspizieren oder starten. "
"Skills laeufst Du als Brain-Tool: `run_<skill_name>` "
"(z.B. `run_yt_dlp_download`). `skill_list` zeigt verfuegbare.\n"
" - `localhost` in Deinem Bash heisst aria-proxy, NICHT "
"aria-brain. Brain ist via Docker-Net erreichbar als "
"`http://aria-brain:8080` (oder Alias `http://brain:8080`). "
"ABER: in 99% der Faelle willst Du das gar nicht — nutze die "
"Brain-Tools direkt (`oauth_get_token`, `memory_search`, …), "
"die sind eine Tool-Call-Ebene hoeher und schneller.\n"
" - `BRAIN_INTERNAL_URL` ist NUR in laufenden Skills gesetzt, "
"NICHT in Deinem Bash-Env. Wenn Du `env | grep BRAIN` machst "
"und nichts findest: das ist normal, Du bist hier nicht in "
"einem Skill.\n"
"\n"
"Was Du DOCH von hier aus kannst:\n"
" - Per `ssh aria@host` zur VM-Host wechseln — der ed25519-"
"Key liegt unter /root/.ssh/. Dort bist Du `aria` mit sudo "
"und voller Linux-Power. Fuer Pentest, Admin, komplexe Tasks "
"der richtige Weg.\n"
" - Externe APIs direkt anpingen (Spotify, GitHub etc.) — "
"curl reicht. Token holst Du Dir per Brain-Tool "
"`oauth_get_token('<service>')` und packst ihn in den curl-"
"Header. Aber: das ist Ad-hoc. Fuer wiederkehrendes baust Du "
"einen Skill (siehe no-skill-drift Regel).\n"
"\n"
"Anti-Pattern (47 Sekunden Stefan-Lebenszeit, am 29.05.2026): "
"12 Bash-Versuche mit python3/python/jq/lokales /data/skills "
"→ alles fehlte. Erst nach 9 Tries kapiert dass `localhost` "
"der falsche Host ist. Bei jedem Bash-Call gegen 'lokale' "
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
),
},
{
"migration_key": "seed/architecture/brain-tools-xml-tag",
"type": "rule",
"title": "Architektur: Brain-Tools per <tool_call>-XML-Tag, nicht als native Tool-Use",
"category": "architektur",
"content": (
"Brain-Tools (run_*, oauth_*, memory_*, trigger_*, skill_*, "
"flux_*) sind KEINE nativen claude-CLI-Tools wie Bash/Read/"
"Write. Sie sind ueber eine Prompt-Injection-Pipeline an "
"claude-max-api-proxy gekoppelt:\n"
"\n"
" - claude-CLI kennt nur Bash/Read/Write/Grep/Glob/etc. nativ\n"
" - Brain-Tools werden im System-Prompt als '# Verfuegbare "
"Tools'-Block mit ihrem Schema injiziert\n"
" - Der Proxy parsed <tool_call name=\"X\">{json}</tool_call>-"
"XML-Tags im Antwort-Text und konvertiert sie zu OpenAI "
"tool_call-Format das ans Brain zurueckgeht\n"
"\n"
"Konkret heisst das: Wenn Du `run_spotify` benutzen willst, "
"schreib es als TEXT in Deine Antwort:\n"
"\n"
" <tool_call name=\"run_spotify\">{\"path\":\"/v1/me/player\"}</tool_call>\n"
"\n"
"NICHT als nativen Tool-Use. Wenn Du es als nativen Tool-Use "
"versuchst, bekommst Du '<tool_use_error>No such tool "
"available: run_spotify</tool_use_error>' — claude-CLI hat das "
"Tool gar nicht im Schema, nur als Prompt-Beschreibung.\n"
"\n"
"Antipattern (Stefan beobachtete das am 30.05.2026): ARIA "
"versucht erst `run_spotify` nativ → 'No such tool' → "
"31 Sekunden verschwendet bis sie das XML-Tag-Format probiert. "
"Beim ersten Versuch direkt XML-Tag ergibt 3-5s statt 30s+."
),
},
{
"migration_key": "seed/skill-rule/no-blind-retry-side-effects",
"type": "rule",
"title": "Skill-Regel: Side-Effect-Tools NIEMALS blind retry'en",
"category": "skills",
"content": (
"Wenn ein Tool eine ZUSTANDS-Aenderung macht (POST, PUT, DELETE, "
"next/previous/play/pause, send-message, transfer-funds, "
"create-trigger, …) und das Result unklar ist (leer, "
"merkwuerdig, scheinbar fehlerhaft): NIEMALS blind nochmal "
"ausfuehren. Side-Effects sind nicht idempotent — zweimal "
"POST /previous = zweimal zurueck, nicht einmal.\n"
"\n"
"Richtiger Reflex:\n"
" 1. State pruefen (currently-playing fuer Spotify, GET fuer "
"REST, list-Endpoint allgemein)\n"
" 2. Vergleichen: ist die gewuenschte Aenderung schon "
"passiert?\n"
" 3. WENN ja → Stefan ehrlich sagen 'lief schon, hier der "
"neue Zustand'\n"
" 4. WENN nein → erst dann Aktion wiederholen\n"
"\n"
"Bei GET-Calls / List-Endpoints / Search ist Retry hingegen ok "
"— die haben keine Side-Effects.\n"
"\n"
"HTTP 204 No Content ist KEIN Fehler. Bei Spotify POST/PUT "
"(next/previous/play/pause/volume/seek) ist 204 die normale "
"Erfolgsantwort. Wenn dein Skill bei 204 einen Parse-Error "
"wirft: skill_update mit `if status == 204: print('OK')` "
"VOR dem Retry, nicht erst die Aktion nochmal auslоsen.\n"
"\n"
"Antipattern (30.05.2026): ARIA hat POST /previous einmal "
"gemacht (Spotify 204 OK → Skill-Parse-Error), dachte 'Skill "
"kaputt', patchte ihn UND fuehrte das previous nochmal aus. "
"Folge: Stefan landete zwei Lieder weiter hinten als gewollt."
),
},
{
"migration_key": "seed/skill-rule/arg-env-convention",
"type": "rule",
"title": "Skill-Regel: Args kommen als ARG_<NAME> ENV — die Konvention NIEMALS aendern",
"category": "skills",
"content": (
"Skill-Args werden vom Brain-Runner als Environment-Variablen "
"mit PRÄFIX `ARG_` ueber `os.environ` an den Skill durchgereicht. "
"Beispiel: arg `path=\"/v1/me/player\"` → "
"`ARG_PATH=/v1/me/player` im Skill-ENV.\n"
"\n"
"Beim skill_update MUSST Du diese Konvention beibehalten:\n"
" RICHTIG: os.environ.get('ARG_PATH', '')\n"
" RICHTIG: os.environ.get('ARG_METHOD', 'GET')\n"
" RICHTIG: os.environ.get('ARG_BODY', '')\n"
"\n"
" FALSCH: os.environ.get('PATH', '') ← System-PATH "
"(Executable-Suchpfad)!\n"
" FALSCH: os.environ.get('METHOD', '')\n"
" FALSCH: os.environ.get('BODY', '')\n"
"\n"
"Antipattern (30.05.2026): ARIA hat beim skill_update des "
"spotify-Skills die Args von `ARG_PATH` auf `PATH` umbenannt. "
"Folge: Skill las `/usr/local/sbin:/usr/local/bin:...` als "
"URL-Pfad → Spotify gab 404 zurück. Stefan dachte Spotify sei "
"kaputt. Rollback noetig.\n"
"\n"
"Andere reservierte ENV-Namen die Du NICHT nehmen darfst: "
"PATH, HOME, USER, SHELL, LANG, TERM, PWD, OLDPWD, "
"BRAIN_INTERNAL_URL, SKILL_DIR, SHARED_UPLOADS, CFG_* "
"(letztere sind Config-Schema-Werte). Bei Skill-Args IMMER "
"den Praefix ARG_ verwenden, dann hast Du keine Kollision."
),
},
{
"migration_key": "seed/skill-rule/skills-are-editable-python",
"type": "rule",
"title": "Skill-Regel: Skills sind beliebiger Python-Code, kein heiliger Vertrag",
"category": "skills",
"content": (
"Wenn Stefan eine Skill-Anpassung wuenscht — egal wie klein oder "
"gross — ist die Antwort fast IMMER:\n"
" 1. `skill_get('<name>')` aufrufen, Code lesen\n"
" 2. Ueberlegen wie sich Stefans Wunsch im Code umsetzen laesst\n"
" 3. `skill_update` mit dem neuen `entry_code`\n"
"\n"
"Skills sind GANZ NORMALER Python-Code. Du darfst und SOLLST:\n"
" - if-elif-else-Verzweigungen auf args / paths reagieren lassen "
"(z.B. `if action == 'current': pretty_output(); else: print(json.dumps(data))`)\n"
" - json.loads(), neue Helper-Funktionen, pip-Pakete via "
"pip_packages ergaenzen\n"
" - Outputs strukturieren oder filtern\n"
" - Mehrere Endpoints einer API in einem Skill bedienen\n"
"\n"
"Was Du NICHT sagen sollst (Antipattern, am 30.05.2026 passiert):\n"
" - 'Der Skill ist ein OAuth2-API-Wrapper, ich kann das nicht in "
"den Wrapper bauen' — Quatsch, Wrapper ist auch nur Python\n"
" - 'Ich schlage einen neuen Skill statt Update vor' — pruefe "
"ZUERST ob skill_update reicht. Anti-Friedhof greift ohnehin "
"wenn der Name kollidiert.\n"
" - 'Kann ich nicht' OHNE Code gelesen zu haben — erst "
"skill_get, dann beurteilen\n"
"\n"
"Stefan ist KEIN Python-Entwickler. Er nennt das ZIEL ('strukturierte "
"Track-Ausgabe bei welches-Lied'), Du baust das WIE im Code. "
"Wenn Du Dich rausredest, ist das Verschwendung — Stefan muss sich "
"dann selbst Python-Tipps merken die er nicht im Kopf hat. "
"Genau dafuer bist Du da."
),
},
{
"migration_key": "seed/skill-rule/scaffold-reflex",
"type": "rule",
"title": "Skill-Regel: Skill-Frage statt Skill-Reflex",
"category": "skills",
"content": (
"Wenn Du dieselbe API mehrmals per Bash anrufst, frag Dich:\n"
"\n"
"1. **Parametrisierbar?** Stabile 1-5 Args (action, path, body) "
"→ Skill-Kandidat. Jeder Aufruf anders (neuer Endpoint, "
"modifizierter Body, neue Hypothese) → KEIN Skill.\n"
"\n"
"2. **Wiederkehrend?** Stefan wird das mehrfach pro Tag/Woche "
"brauchen → ja. Einmal-Spike heute → nein.\n"
"\n"
"3. **Exploratory?** Pentest, Audit, Code-Review, Reverse-"
"Engineering, Recherche → Hypothesen-Iteration. KEIN Skill, "
"auch wenn 100x derselbe Host. Bleib bei ad-hoc Bash oder "
"`ssh aria@host` zur VM-Host.\n"
"\n"
"4. **Im Zweifel: frag Stefan.** Lieber 5 Sekunden Bestaetigung "
"als zehn unsinnige Skills im Friedhof. Beispiele:\n"
" - 'Stefan, das ist mein 3. X-Call diese Woche — soll ich "
"daraus einen Skill machen?'\n"
" - 'Das hier ist Pentest-Workflow, ich bleibe bei ad-hoc "
"Bash, ok?'\n"
"\n"
"Du musst NICHT automatisch scaffolden. Brain trackt NICHT mehr "
"wer wieviele Calls gegen welchen Host gemacht hat. Du "
"entscheidest mit Sinn und Verstand — oder fragst nach.\n"
"\n"
"Wenn Du einen Skill bauen willst, hast Du drei Tools:\n"
" - `skill_scaffold` mit Template — einfachster Weg fuer "
"Standard-Pattern (siehe oauth-api/apikey-api/file-process).\n"
" - `skill_create` mit eigenem entry_code — fuer alles was "
"in kein Template passt.\n"
" - `skill_update` — wenn ein vorhandener Skill nur erweitert "
"werden muss (was meistens der Fall ist)."
),
},
{
"migration_key": "seed/skill-rule/patch-before-diagnose",
"type": "rule",
"title": "Skill-Regel: vor skill_update erst skill_get lesen + API-Errors zitieren statt raten",
"category": "skills",
"content": (
"Zwei Antipattern die zusammenhaengen — beide am 30.05.2026 "
"live beobachtet:\n"
"\n"
"**1. Vor jedem `skill_update`: ZUERST `skill_get` lesen.** "
"Frag Dich: ist das vermutete Problem wirklich noch im Code? "
"Symptome != Diagnose. Vorfall: Spotify-Skill gab 403, ARIA "
"vermutete 'der 204-Bug ist zurueck' und patchte den Skill — "
"zweimal hintereinander. Der 204-Fix war aber laengst drin. "
"Sie hatte das durch `skill_get` in 5 Sekunden klaeren koennen.\n"
"\n"
"Vor jedem skill_update also der Reflex:\n"
" - `skill_get('<name>')` -> Code anschauen\n"
" - Symptome durchdenken: ist mein vermuteter Bug ueberhaupt "
"der echte? Oder ist der Fehler woanders (Spotify-API, "
"User-Kontext, Tool-Args)?\n"
" - Nur dann patchen wenn der Code-Befund das wirklich "
"rechtfertigt.\n"
"\n"
"**2. Bei HTTP-Errors aus API-Skills (4xx/5xx): die echte "
"Response-Body ZITIEREN, nicht die Bedeutung raten.** "
"Vorfall: Spotify gab 403 'Restriction violated'. ARIA "
"antwortete 'war schon pausiert, daher der 403' — das war "
"geraten, nicht aus den Daten gelesen. 403 'Restriction "
"violated' kann viele Sachen heissen:\n"
" - NO_ACTIVE_DEVICE (kein Spotify-Geraet ausgewaehlt)\n"
" - ALREADY_PAUSED / ALREADY_PLAYING\n"
" - PREMIUM_REQUIRED\n"
" - MARKET_RESTRICTED / DEVICE_NOT_CONTROLLABLE\n"
"Spotify gibt die wahre Ursache als `error.reason` im JSON-"
"Body zurueck. Lies sie aus, sag sie Stefan 1:1. Wenn die "
"Skill-Output das verschluckt: skill_update mit error.reason-"
"Extraktion (nach skill_get!), damit Du beim naechsten Mal "
"die echte Info hast.\n"
"\n"
"Plausibel-aber-geraten ist schlimmer als 'ich weiss es nicht' "
"— Stefan verlaesst sich auf Deine Antworten."
),
},
{
"migration_key": "seed/skill-rule/external-api-auth-strategy",
"type": "rule",
"title": "Skill-Regel: Auth-Strategie fuer externe APIs",
"category": "skills",
"content": (
"Wenn dein Skill mit einer externen API redet (Spotify, Google, "
"Reddit, GitHub, OpenWeather, OpenAI, …), entscheide IMMER bewusst "
"die Auth-Strategie in dieser Reihenfolge:\n"
" 1. OAuth2? (Spotify, Google, GitHub, Reddit, Discord, Twitch, "
"Microsoft, …) -> nutze `oauth_register_provider` falls der "
"Provider noch nicht da ist, dann `oauth_authorize` fuer "
"Initial-Login. Im Skill: Token via "
"BRAIN_INTERNAL_URL/oauth/<service>/token holen — Brain macht "
"Auto-Refresh, Stefan muss sich nicht alle 60min neu einloggen.\n"
" 2. Statischer API-Key / Bearer-Token? (OpenWeather, OpenAI, "
"Twilio, SendGrid, …) -> in skill.json `config_schema` "
"deklarieren. Stefan setzt den Wert in Diagnostic, Skill bekommt "
"ihn als CFG_<NAME> ENV.\n"
" 3. NIEMALS hardcoden — egal wie 'temporaer' es ist.\n"
"Wenn Du nicht sicher bist welche Strategie ein Service nutzt: "
"in der API-Doku des Services nachsehen ('OAuth' oder "
"'API Key' im Auth-Kapitel). Nicht raten."
),
},
]
def apply(store: VectorStore, embedder: Embedder) -> dict:
"""Schreibt alle SEED_RULES idempotent in die DB.
Vorgehen: erst alle Punkte mit `source=seed` UND passender migration_key
loeschen, dann frisch upserten. So koennen Regeln editiert/entfernt
werden indem die SEED_RULES-Liste angepasst wird.
"""
if not SEED_RULES:
return {"written": 0}
migration_keys = [r["migration_key"] for r in SEED_RULES]
# Alte Versionen entfernen (nur die mit unserer migration_key — andere
# source=seed Punkte aus zukuenftigen seed-Files sind sicher)
try:
store.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
qm.FieldCondition(key="migration_key", match=qm.MatchAny(any=migration_keys))
])),
)
except Exception as exc:
logger.warning("seed_rules: delete-by-migration_key fehlgeschlagen (%s) — wahrscheinlich erster Run", exc)
# Frisch einbetten + schreiben
texts = [r["content"] for r in SEED_RULES]
vectors = embedder.embed_batch(texts)
now = datetime.now(timezone.utc).isoformat()
written = 0
for rule, vec in zip(SEED_RULES, vectors):
payload = {
"type": rule["type"],
"title": rule["title"],
"content": rule["content"],
"pinned": True,
"category": rule.get("category", ""),
"source": "seed",
"tags": [],
"created_at": now,
"updated_at": now,
"migration_key": rule["migration_key"],
"attachments": [],
}
store.client.upsert(
collection_name=COLLECTION,
points=[qm.PointStruct(id=str(uuid.uuid4()), vector=vec, payload=payload)],
)
written += 1
logger.info("seed_rules: %d Regeln in DB geschrieben", written)
return {"written": written, "keys": migration_keys}
+460
View File
@@ -0,0 +1,460 @@
"""
Skill-Templates — Boilerplate fuer haeufige Skill-Pattern.
ARIA muss nicht jedes Mal einen kompletten Python-Skill aus dem Nichts
generieren. Sie ruft `skill_scaffold(name, template, params)`, Brain
expandiert das Template und legt den Skill an. Hoehere Skill-Adoption
weil niedrigere Bauh-Huerde.
Templates sind ueber Token-Replacement parametrisiert (kein f-String —
das wuerde mit dem skill-internen Python-Code kollidieren).
"""
from __future__ import annotations
import re
from typing import Callable
# ── Hilfsfunktion ────────────────────────────────────────────────────
def _replace_tokens(s: str, tokens: dict) -> str:
"""Ersetzt {{TOKEN}}-Platzhalter durch Werte. Robust gegen f-String-
Konflikte im Python-Code des Skills."""
out = s
for k, v in tokens.items():
out = out.replace("{{" + k + "}}", str(v))
return out
# ── Template 1: oauth-api ────────────────────────────────────────────
# Wrappt eine OAuth2-API. Token kommt aus dem Brain (Auto-Refresh).
_OAUTH_API_CODE = '''"""
{{NAME}} — OAuth2-API-Wrapper fuer {{SERVICE}}.
Holt Token vom Brain (Auto-Refresh) und ruft HTTP-Endpoints der {{SERVICE}}-API.
Keine hardcoded Credentials — alles ueber das zentrale OAuth-System.
Args (alle als ENV ARG_<NAME>):
ARG_METHOD = GET | POST | PUT | DELETE | PATCH (Default GET)
ARG_PATH = API-Pfad inkl. Query-String (z.B. /v1/me/player)
ARG_BODY = JSON-Body als String (optional, fuer POST/PUT/PATCH)
ARG_BASE_URL = Override der Default-Base-URL (optional)
Exit-Codes: 0 ok, 1 Fehler, 2 nicht autorisiert (Re-Login noetig)
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BRAIN_URL = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
SERVICE = "{{SERVICE}}"
def get_token() -> str:
try:
with urllib.request.urlopen(
f"{BRAIN_URL}/oauth/{SERVICE}/token", timeout=10,
) as r:
return json.loads(r.read())["access_token"]
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")[:400]
if e.code == 401:
print(f"NICHT AUTORISIERT: {SERVICE}-Token abgelaufen oder nie gesetzt. "
f"ARIA-Tool 'oauth_authorize' nutzen. Details: {body}", file=sys.stderr)
sys.exit(2)
print(f"Token-Holen fehlgeschlagen: HTTP {e.code} - {body}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Token-Holen fehlgeschlagen: {e}", file=sys.stderr)
sys.exit(1)
def main() -> int:
method = (os.environ.get("ARG_METHOD") or "GET").upper()
path = (os.environ.get("ARG_PATH") or "").strip()
body_raw = (os.environ.get("ARG_BODY") or "").strip()
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not path:
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
return 1
if not path.startswith("/"):
path = "/" + path
url = base_url + path
headers = {"Authorization": f"Bearer {get_token()}"}
data = None
if body_raw and method in ("POST", "PUT", "PATCH"):
data = body_raw.encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
txt = r.read().decode("utf-8")
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
ensure_ascii=False, indent=2))
return 0
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", "replace")
try: parsed = json.loads(txt)
except Exception: parsed = txt[:800]
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
ensure_ascii=False, indent=2))
return 1
if __name__ == "__main__":
sys.exit(main())
'''
_OAUTH_API_README = '''# {{NAME}}
OAuth2-API-Wrapper fuer **{{SERVICE}}**. Generiert via `skill_scaffold(template="oauth-api")`.
Holt den Token vom Brain (Auto-Refresh) und macht beliebige HTTP-Calls gegen
die {{SERVICE}}-API. Keine hardcoded Credentials — die Auth-Pipeline laeuft
zentral ueber das Brain-OAuth-System.
## Voraussetzung
- OAuth-App fuer **{{SERVICE}}** im Brain registriert (Diagnostic → OAuth-Apps → client_id + client_secret eintragen)
- Einmaliges `oauth_authorize {{SERVICE}}` zum Initial-Login
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| method | GET | HTTP-Methode (GET/POST/PUT/DELETE/PATCH) |
| path | - | API-Pfad mit Query-String (z.B. `/v1/me/player`) |
| body | - | JSON-Body fuer POST/PUT/PATCH |
| base_url | {{DEFAULT_BASE_URL}} | Override der Base-URL falls Sub-API |
## Beispiele
```
method=GET path=/v1/me/player # Was laeuft?
method=POST path=/v1/me/player/next # Skip
method=PUT path=/v1/me/player/volume?volume_percent=40 # Volume 40
```
Antwort: `{ok, status, data}` als JSON. Bei Fehler `ok=false`.
'''
def _oauth_api(name: str, params: dict) -> dict:
service = (params.get("service") or name).strip().lower()
default_base_url = params.get("base_url") or f"https://api.{service}.com"
tokens = {
"NAME": name,
"SERVICE": service,
"DEFAULT_BASE_URL": default_base_url,
}
return {
"entry_code": _replace_tokens(_OAUTH_API_CODE, tokens),
"readme": _replace_tokens(_OAUTH_API_README, tokens),
"pip_packages": [],
"args": [
{"name": "method", "type": "string", "required": False,
"description": "HTTP-Methode (Default GET)"},
{"name": "path", "type": "string", "required": True,
"description": "API-Pfad inkl. Query-String, z.B. /v1/me/player"},
{"name": "body", "type": "string", "required": False,
"description": "JSON-Body fuer POST/PUT/PATCH"},
{"name": "base_url", "type": "string", "required": False,
"description": f"Override der Base-URL (Default {default_base_url})"},
],
"config_schema": [],
"description": f"OAuth2-API-Wrapper fuer {service}. Token kommt vom Brain (Auto-Refresh).",
}
# ── Template 2: apikey-api ───────────────────────────────────────────
# Wrappt eine API die mit statischem API-Key/Bearer-Token arbeitet.
# Key liegt in skill.json::config_schema und wird via CFG_<KEY> ENV
# durchgereicht — kein hardcoden, Stefan setzt's in Diagnostic.
_APIKEY_API_CODE = '''"""
{{NAME}} — API-Wrapper fuer {{API_NAME}} mit statischem Key.
Schluessel kommt aus dem Skill-Config (CFG_{{KEY_ENV}}) — Stefan setzt
ihn im Diagnostic-UI bzw. App, NICHT hardcoded.
Args:
ARG_METHOD = GET | POST | PUT | DELETE (Default GET)
ARG_PATH = API-Pfad inkl. Query-String
ARG_BODY = JSON-Body (optional)
ARG_BASE_URL = Override der Default-Base-URL
Exit-Codes: 0 ok, 1 Fehler, 2 Key nicht gesetzt
"""
import json
import os
import sys
import urllib.error
import urllib.request
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
AUTH_HEADER = "{{AUTH_HEADER}}" # z.B. "Authorization" oder "X-Api-Key"
AUTH_PREFIX = "{{AUTH_PREFIX}}" # z.B. "Bearer " oder leer
def main() -> int:
key = os.environ.get("CFG_{{KEY_ENV}}", "").strip()
if not key:
print(json.dumps({"ok": False,
"error": "API-Key nicht gesetzt — in Diagnostic Skill-Config '{{KEY_ENV}}' eintragen"}),
file=sys.stderr)
return 2
method = (os.environ.get("ARG_METHOD") or "GET").upper()
path = (os.environ.get("ARG_PATH") or "").strip()
body_raw = (os.environ.get("ARG_BODY") or "").strip()
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not path:
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
return 1
if not path.startswith("/"):
path = "/" + path
url = base_url + path
headers = {AUTH_HEADER: f"{AUTH_PREFIX}{key}"}
data = None
if body_raw and method in ("POST", "PUT", "PATCH"):
data = body_raw.encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
txt = r.read().decode("utf-8")
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
ensure_ascii=False, indent=2))
return 0
except urllib.error.HTTPError as e:
txt = e.read().decode("utf-8", "replace")
try: parsed = json.loads(txt)
except Exception: parsed = txt[:800]
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
ensure_ascii=False, indent=2))
return 1
if __name__ == "__main__":
sys.exit(main())
'''
_APIKEY_API_README = '''# {{NAME}}
API-Wrapper fuer **{{API_NAME}}** mit statischem API-Key. Generiert via
`skill_scaffold(template="apikey-api")`.
Schluessel ist NICHT im Code, sondern im Skill-Config (`CFG_{{KEY_ENV}}`).
Stefan setzt ihn in Diagnostic → Skills → Detail → Konfiguration.
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| method | GET | HTTP-Methode |
| path | - | API-Pfad mit Query-String |
| body | - | JSON-Body |
| base_url | {{DEFAULT_BASE_URL}} | Override |
## Config (in Diagnostic einstellen)
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| {{KEY_ENV}} | password | API-Key fuer {{API_NAME}} |
'''
def _apikey_api(name: str, params: dict) -> dict:
api_name = params.get("api_name") or name
key_env = (params.get("key_env") or "API_KEY").upper()
# safe: nur Buchstaben/Zahlen/Underscore
key_env = re.sub(r"[^A-Z0-9_]", "_", key_env)
auth_header = params.get("auth_header") or "Authorization"
auth_prefix = params.get("auth_prefix") if "auth_prefix" in params else "Bearer "
default_base_url = params.get("base_url") or "https://api.example.com"
tokens = {
"NAME": name,
"API_NAME": api_name,
"KEY_ENV": key_env,
"AUTH_HEADER": auth_header,
"AUTH_PREFIX": auth_prefix,
"DEFAULT_BASE_URL": default_base_url,
}
return {
"entry_code": _replace_tokens(_APIKEY_API_CODE, tokens),
"readme": _replace_tokens(_APIKEY_API_README, tokens),
"pip_packages": [],
"args": [
{"name": "method", "type": "string", "required": False,
"description": "HTTP-Methode (Default GET)"},
{"name": "path", "type": "string", "required": True,
"description": "API-Pfad inkl. Query-String"},
{"name": "body", "type": "string", "required": False,
"description": "JSON-Body fuer POST/PUT"},
{"name": "base_url", "type": "string", "required": False,
"description": "Override der Base-URL"},
],
"config_schema": [
{"name": key_env, "type": "password", "label": f"{api_name} API-Key",
"secret": True, "description": f"Persoenlicher API-Key fuer {api_name}"},
],
"description": f"API-Wrapper fuer {api_name} (Key aus CFG_{key_env}).",
}
# ── Template 3: file-process ─────────────────────────────────────────
# Nimmt eine Datei aus /shared/uploads/, ruft eine User-Funktion drauf
# auf, schreibt das Resultat nach /shared/uploads/. Skelett — ARIA fuellt
# die `process()`-Funktion danach via skill_update mit dem echten Code.
_FILE_PROCESS_CODE = '''"""
{{NAME}} — File-Processing-Skelett.
Liest eine Eingabe-Datei aus /shared/uploads/, ruft process() auf,
schreibt Output zurueck nach /shared/uploads/.
Args:
ARG_INPUT = Pfad zur Eingabedatei (z.B. /shared/uploads/foo.pdf)
ARG_OUTPUT = Optional Pfad fuer Output (Default: <input>.{{OUTPUT_EXT}})
ARIA-Hinweis: die process()-Funktion ist ein Stub — passe sie via
skill_update an deine Aufgabe an. pip_packages bei Bedarf via
skill_update ergaenzen (z.B. pypdf, Pillow, reportlab).
"""
import os
import shutil
import sys
def process(input_path: str, output_path: str) -> None:
"""Eigentlicher Verarbeitungs-Schritt. Hier kommt der Code rein."""
# STUB: kopiert die Datei einfach. ARIA: ueberschreibe diese Funktion.
shutil.copy(input_path, output_path)
def main() -> int:
inp = (os.environ.get("ARG_INPUT") or "").strip()
if not inp:
print("FEHLER: ARG_INPUT erforderlich", file=sys.stderr)
return 1
if not os.path.exists(inp):
print(f"FEHLER: Eingabe nicht gefunden: {inp}", file=sys.stderr)
return 1
out = (os.environ.get("ARG_OUTPUT") or "").strip()
if not out:
base, _ = os.path.splitext(inp)
out = f"{base}.{{OUTPUT_EXT}}"
try:
process(inp, out)
except Exception as e:
print(f"FEHLER bei process(): {e}", file=sys.stderr)
return 1
print(out) # stdout = Pfad zur Ausgabe-Datei, ARIA kann den dem User zurueckgeben
return 0
if __name__ == "__main__":
sys.exit(main())
'''
_FILE_PROCESS_README = '''# {{NAME}}
File-Processing-Skelett (`skill_scaffold(template="file-process")`).
Liest eine Datei aus `/shared/uploads/`, ruft die `process()`-Funktion auf,
schreibt das Resultat zurueck. Die `process()`-Funktion ist initial ein
Stub (kopiert nur) — ARIA passt sie via `skill_update` an die konkrete
Aufgabe an.
## Args
| Name | Default | Beschreibung |
|------|---------|--------------|
| input | - | Eingabedatei (z.B. /shared/uploads/foo.pdf) |
| output | `<input>.{{OUTPUT_EXT}}` | Ausgabepfad (optional) |
stdout = Pfad zur erzeugten Datei → ARIA kann ihn dem User zurueckgeben.
'''
def _file_process(name: str, params: dict) -> dict:
output_ext = (params.get("output_ext") or "out").strip().lstrip(".")
output_ext = re.sub(r"[^a-zA-Z0-9]", "", output_ext) or "out"
tokens = {
"NAME": name,
"OUTPUT_EXT": output_ext,
}
return {
"entry_code": _replace_tokens(_FILE_PROCESS_CODE, tokens),
"readme": _replace_tokens(_FILE_PROCESS_README, tokens),
"pip_packages": [],
"args": [
{"name": "input", "type": "string", "required": True,
"description": "Eingabedatei (z.B. /shared/uploads/foo.pdf)"},
{"name": "output", "type": "string", "required": False,
"description": f"Output-Pfad (Default <input>.{output_ext})"},
],
"config_schema": [],
"description": f"File-Processing-Skelett (Input → process() → Output.{output_ext}).",
}
# ── Registry ────────────────────────────────────────────────────────
TEMPLATES: dict[str, Callable[[str, dict], dict]] = {
"oauth-api": _oauth_api,
"apikey-api": _apikey_api,
"file-process": _file_process,
}
def list_templates() -> list[dict]:
"""Liste aller verfuegbaren Templates mit Kurzbeschreibung — fuer UI/Tool-Doku."""
return [
{
"name": "oauth-api",
"description": "OAuth2-API-Wrapper (Spotify, GitHub, Reddit, Google, …). "
"Token kommt vom Brain mit Auto-Refresh. Args: method/path/body.",
"params": ["service (str, OAuth-Service-Name)", "base_url (str, optional)"],
},
{
"name": "apikey-api",
"description": "API-Wrapper fuer Services mit statischem API-Key "
"(OpenWeather, OpenAI, Twilio, …). Key liegt im Skill-Config "
"und kommt als CFG_<NAME> ENV — kein hardcode.",
"params": ["api_name (str)", "key_env (str, ENV-Name fuer den Key)",
"auth_header (str, default 'Authorization')",
"auth_prefix (str, default 'Bearer ')",
"base_url (str)"],
},
{
"name": "file-process",
"description": "Skelett fuer File-In/File-Out-Operationen "
"(PDF konvertieren, Bild bearbeiten, JSON umformen). "
"process()-Funktion ist Stub, ARIA fuellt sie via skill_update.",
"params": ["output_ext (str, Datei-Endung des Outputs)"],
},
]
def expand(name: str, template: str, params: dict | None = None) -> dict:
"""Expandiert ein Template zu einem fertigen Skill-Spec.
Returns: dict mit entry_code / readme / pip_packages / args /
config_schema / description — direkt an create_skill weitergebbar.
Wirft ValueError wenn das Template nicht existiert.
"""
fn = TEMPLATES.get(template)
if not fn:
raise ValueError(
f"Template '{template}' unbekannt. Verfuegbar: {sorted(TEMPLATES.keys())}"
)
return fn(name, params or {})
+412
View File
@@ -47,9 +47,15 @@ logger = logging.getLogger(__name__)
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
SHARED_UPLOADS = Path("/shared/uploads")
SKILL_CONFIGS_FILE = Path(os.environ.get("SKILL_CONFIGS_FILE", "/shared/config/skill_configs.json"))
# Beim Archivieren in versions/ ausgenommen (gross, regenerierbar, sind keine Sources)
_VERSION_SKIP = {"venv", "logs", "versions", "__pycache__"}
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
# Anti-Skill-Friedhof: ARIAs Lieblings-Suffixe wenn sie statt updaten neu baut
VERSION_SUFFIX_RE = re.compile(r"(?:[-_]v\d+|[-_](?:new|fixed|old|alt|copy|final|clean))$", re.I)
def _now() -> str:
@@ -66,6 +72,44 @@ def _skill_dir(name: str) -> Path:
return SKILLS_DIR / _safe_name(name)
def _check_anti_graveyard(name: str) -> None:
"""Verhindert klassische Skill-Friedhof-Patterns beim Anlegen.
Hard-Reject auf:
1. Versions-Suffixe (`-v2`, `_v3`, `-new`, `-fixed`, …) im Namen
2. Prefix-Kollision mit existierendem Skill (z.B. `spotify` existiert,
jemand will `spotify-aria` oder `spotify-ctl` anlegen)
"""
if VERSION_SUFFIX_RE.search(name):
raise ValueError(
f"Skill-Name '{name}' enthaelt einen Versions-Suffix "
f"(-v2 / _v3 / -new / -fixed / -old / -alt / -copy / -final / -clean). "
f"Skills werden intern versioniert (skill_rollback). "
f"Waehle einen klaren Namen ohne Suffix oder nutze skill_update auf "
f"den bestehenden Skill."
)
if not SKILLS_DIR.exists():
return
existing = [p.name for p in SKILLS_DIR.iterdir() if p.is_dir()]
for ex in existing:
if ex == name:
continue # wird spaeter mit "existiert bereits" abgefangen
# neuer Name verlaengert existierenden Stem: 'spotify' da, neu 'spotify-aria'
if name.startswith(ex + "-") or name.startswith(ex + "_"):
raise ValueError(
f"Skill-Name '{name}' kollidiert mit existierendem '{ex}'. "
f"Wenn Du '{ex}' verbessern willst: skill_update auf '{ex}'. "
f"Wenn es wirklich was anderes ist: waehle einen Namen ohne den "
f"Praefix '{ex}-' / '{ex}_'."
)
# neuer Name ist Kurzform eines existierenden: 'spotify-aria' da, neu 'spotify'
if ex.startswith(name + "-") or ex.startswith(name + "_"):
raise ValueError(
f"Es existiert bereits '{ex}' mit Praefix '{name}'. Pruefe ob '{ex}' "
f"das schon kann; wenn ja: skill_update auf '{ex}' oder Skill umbenennen."
)
# ─── Listing ────────────────────────────────────────────────────────
def list_skills(active_only: bool = False) -> list[dict]:
@@ -119,6 +163,7 @@ def create_skill(
requires: Optional[dict] = None,
pip_packages: Optional[list[str]] = None,
author: str = "aria",
config_schema: Optional[list] = None,
) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -128,6 +173,7 @@ def create_skill(
name = _safe_name(name)
if execution not in VALID_EXECUTIONS:
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
_check_anti_graveyard(name)
d = _skill_dir(name)
if d.exists():
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
@@ -166,6 +212,8 @@ def create_skill(
"use_count": 0,
"version": "1.0",
"author": author,
"config_schema": _normalize_config_schema(config_schema),
"version_history": [],
}
write_manifest(name, manifest)
@@ -184,6 +232,35 @@ def create_skill(
return manifest
def _normalize_config_schema(schema: Optional[list]) -> list:
"""Filter + Normalisiert das config_schema. Erwartet Liste von Dicts mit
Pflichtfeld 'name'. Optional: label, type (string|number|boolean|password),
secret (bool), default, description."""
if not schema:
return []
out = []
for f in schema:
if not isinstance(f, dict):
continue
fname = (f.get("name") or "").strip()
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,40}$", fname):
continue
ftype = (f.get("type") or "string").lower()
if ftype not in ("string", "number", "boolean", "password"):
ftype = "string"
# password impliziert secret=True
secret = bool(f.get("secret")) or ftype == "password"
out.append({
"name": fname,
"type": ftype,
"label": (f.get("label") or fname),
"secret": secret,
"description": (f.get("description") or "")[:300],
"default": f.get("default"),
})
return out
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
venv = skill_dir / "venv"
logger.info("venv erstellen: %s", venv)
@@ -194,25 +271,344 @@ def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
def update_skill(name: str, patch: dict) -> dict:
"""Aktualisiert einen bestehenden Skill. Manifest-Felder ueber den
`allowed`-Filter, Code-Aenderungen ueber dedizierte Keys:
- `entry_code` (str) → ueberschreibt run.py / run.sh
- `readme` (str) → ueberschreibt README.md
- `pip_packages` (list) → ueberschreibt requirements.txt + venv-Rebuild
(nur bei local-venv)
"""
manifest = read_manifest(name)
if manifest is None:
raise ValueError(f"Skill '{name}' nicht gefunden")
d = _skill_dir(name)
# Auto-Archive: wenn strukturelle Aenderung (Code/README/Deps/Schema), erst
# snapshot machen. So kann jeder skill_update zurueckgerollt werden.
structural = any(k in patch for k in ("entry_code", "readme", "pip_packages",
"config_schema", "args"))
if structural:
try:
archive_current_version(
name,
summary=patch.get("_change_summary") or ", ".join(
sorted(k for k in patch.keys() if k != "_change_summary")
)[:200],
)
except Exception as exc:
logger.warning("update_skill: Auto-Archive %s fehlgeschlagen: %s", name, exc)
# nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest
allowed = {"description", "args", "requires", "active", "version", "entry"}
for k, v in patch.items():
if k in allowed:
manifest[k] = v
if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
# Code austauschen
if "entry_code" in patch and patch["entry_code"]:
execution = manifest.get("execution", "local-venv")
if execution == "local-venv":
entry_path = d / "run.py"
entry_path.write_text(patch["entry_code"], encoding="utf-8")
else:
entry_path = d / "run.sh"
content = patch["entry_code"] if patch["entry_code"].startswith("#!") else "#!/usr/bin/env bash\nset -euo pipefail\n" + patch["entry_code"]
entry_path.write_text(content, encoding="utf-8")
entry_path.chmod(0o755)
# README austauschen
if "readme" in patch and patch["readme"] is not None:
(d / "README.md").write_text(patch["readme"], encoding="utf-8")
# pip_packages geaendert → requirements.txt + venv neu aufbauen
if "pip_packages" in patch and manifest.get("execution") == "local-venv":
pip_packages = patch["pip_packages"] or []
(d / "requirements.txt").write_text("\n".join(pip_packages) + "\n", encoding="utf-8")
# venv loeschen + neu aufbauen, damit alte Pakete weg sind
venv = d / "venv"
if venv.exists():
shutil.rmtree(venv, ignore_errors=True)
try:
_setup_venv(d, pip_packages)
# Falls vorher wegen Setup-Error deaktiviert war: jetzt frei
manifest.pop("setup_error", None)
manifest["active"] = patch.get("active", True)
except Exception as exc:
manifest["active"] = False
manifest["setup_error"] = str(exc)[:500]
logger.warning("Skill %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
write_manifest(name, manifest)
logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys()))
return manifest
def scaffold_skill(
name: str,
template: str,
params: Optional[dict] = None,
author: str = "aria",
) -> dict:
"""Baut einen Skill aus einem Template-Skelett. ARIA muss nicht jedes Mal
einen kompletten Python-Skill schreiben — sie waehlt ein Template und
optionale Parameter, Brain expandiert das zu fertigem Code.
Templates siehe `skill_templates.TEMPLATES`. Konkret:
- 'oauth-api' : params={service, base_url?}
- 'apikey-api': params={api_name, key_env, auth_header?, auth_prefix?, base_url?}
- 'file-process': params={output_ext?}
Wirft ValueError wenn Template unbekannt oder Name kollidiert.
Sonst: ruft intern create_skill mit den expandierten Feldern auf.
"""
import skill_templates as _st
spec = _st.expand(name, template, params or {})
return create_skill(
name=name,
description=spec["description"],
execution="local-venv",
entry_code=spec["entry_code"],
readme=spec["readme"],
args=spec["args"],
pip_packages=spec["pip_packages"],
config_schema=spec["config_schema"],
author=author,
)
def delete_skill(name: str) -> None:
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
shutil.rmtree(d)
# Configs auch raeumen — sonst Karteileiche in skill_configs.json
try:
all_cfg = _load_all_skill_configs()
if name in all_cfg:
all_cfg.pop(name)
_save_all_skill_configs(all_cfg)
except Exception:
pass
logger.info("Skill geloescht: %s", name)
# ─── Skill-Configs (statische Werte je Skill — API-Keys, IDs etc.) ──
# Werte liegen zentral in /shared/config/skill_configs.json damit Stefan
# sie im Diagnostic-UI editieren kann. Skill bekommt sie zur Laufzeit
# als ENV `CFG_<UPPER_NAME>` — kein hardcoden im Code noetig.
def _load_all_skill_configs() -> dict:
if not SKILL_CONFIGS_FILE.exists():
return {}
try:
return json.loads(SKILL_CONFIGS_FILE.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("skill_configs.json kaputt (%s) — leeres dict", exc)
return {}
def _save_all_skill_configs(data: dict) -> None:
SKILL_CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SKILL_CONFIGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8")
def get_skill_config(name: str) -> dict:
"""Liefert die rohen Config-Werte fuer einen Skill (ungemasked).
Wird intern beim run_skill genutzt um CFG_<NAME>-Env zu bauen."""
return _load_all_skill_configs().get(name, {})
def set_skill_config(name: str, values: dict) -> dict:
"""Speichert die Config-Werte fuer einen Skill (komplett ueberschreiben).
Werte landen sofort persistent; naechster run_skill nutzt sie."""
if not isinstance(values, dict):
raise ValueError("values muss ein Dict sein")
all_cfg = _load_all_skill_configs()
all_cfg[name] = values
_save_all_skill_configs(all_cfg)
return values
def get_skill_config_masked(name: str) -> dict:
"""Wie get_skill_config, aber secret-Felder werden auf '***SET***' maskiert.
Schema kommt aus dem skill.json — Felder ohne secret=True werden klar
zurueckgegeben. Fuer UI-Anzeige."""
manifest = read_manifest(name)
schema = (manifest or {}).get("config_schema") or []
secret_fields = {f.get("name") for f in schema if f.get("secret")}
values = get_skill_config(name)
return {k: ("***SET***" if (k in secret_fields and v) else v)
for k, v in values.items()}
def _config_env_name(field_name: str) -> str:
"""API-Key → CFG_API_KEY. Erlaubt nur a-zA-Z0-9_."""
safe = re.sub(r"[^a-zA-Z0-9]", "_", field_name).upper()
return f"CFG_{safe}"
# ─── Versionierung (Rollback-fähiges update_skill) ───────────────────
# Vor jedem strukturellen update wird der aktuelle Stand nach
# versions/v_<ts>/ kopiert (ohne venv/logs/versions). Rollback kopiert
# eine Version zurueck — vorher noch ein Auto-Snapshot, damit auch der
# Rollback rueckholbar ist.
def _versions_dir(name: str) -> Path:
return _skill_dir(name) / "versions"
def _copytree_skill(src: Path, dst: Path) -> None:
"""Kopiert Skill-Sources (alles ausser venv/logs/versions/__pycache__)."""
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
if item.name in _VERSION_SKIP:
continue
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
def archive_current_version(name: str, summary: str = "") -> str:
"""Kopiert den aktuellen Skill-Stand nach versions/v_<ts>/. Returnt die
version_id. Im Manifest wird `version_history` gepflegt."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
ts = int(time.time())
version_id = f"v_{ts}"
# Kollisionsschutz bei sub-Sekunden-Calls
while (_versions_dir(name) / version_id).exists():
ts += 1
version_id = f"v_{ts}"
archive = _versions_dir(name) / version_id
_copytree_skill(d, archive)
(archive / "_version.json").write_text(json.dumps({
"version_id": version_id,
"archived_at": _now(),
"summary": (summary or "")[:300],
}, indent=2, ensure_ascii=False), encoding="utf-8")
# Manifest-History pflegen (read-back nach _copytree, damit history konsistent)
manifest = read_manifest(name)
if manifest is not None:
hist = list(manifest.get("version_history") or [])
hist.append({"version_id": version_id, "archived_at": _now(),
"summary": (summary or "")[:300]})
# Cap auf 50 Versionen — alte Eintraege wegrotieren (Dateien bleiben aber)
manifest["version_history"] = hist[-50:]
write_manifest(name, manifest)
return version_id
def list_skill_versions(name: str) -> list[dict]:
"""Liste aller archivierten Versionen, neueste zuerst."""
versions = _versions_dir(name)
if not versions.exists():
return []
out = []
for entry in sorted(versions.iterdir(), reverse=True):
if not entry.is_dir():
continue
meta = entry / "_version.json"
if meta.exists():
try:
out.append(json.loads(meta.read_text(encoding="utf-8")))
continue
except Exception:
pass
out.append({"version_id": entry.name, "archived_at": "", "summary": ""})
return out
def rollback_skill(name: str, version_id: str) -> dict:
"""Stellt eine archivierte Version wieder her. Vorher wird der aktuelle
Stand automatisch als neue Version archiviert ('safety_snapshot') —
Rollback ist also nicht destruktiv. venv wird neu aufgebaut wenn
requirements.txt vorhanden ist."""
d = _skill_dir(name)
if not d.exists():
raise ValueError(f"Skill '{name}' nicht gefunden")
archive = _versions_dir(name) / version_id
if not archive.exists() or not archive.is_dir():
raise ValueError(f"Version '{version_id}' fuer Skill '{name}' nicht gefunden")
# 1. Sicherung des aktuellen Stands
safety = archive_current_version(name, summary=f"safety-snapshot vor rollback auf {version_id}")
# 2. Aktuelle Sources loeschen (venv/logs/versions bleiben)
for item in d.iterdir():
if item.name in _VERSION_SKIP:
continue
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
else:
try:
item.unlink()
except FileNotFoundError:
pass
# 3. Archive zurueck kopieren (ohne _version.json — das ist Versions-Metadata)
for item in archive.iterdir():
if item.name == "_version.json":
continue
target = d / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
# 4. Manifest-Stempel
manifest = read_manifest(name)
if manifest is not None:
manifest["updated_at"] = _now()
manifest["last_rollback"] = {"to": version_id, "safety": safety, "at": _now()}
write_manifest(name, manifest)
# 5. venv-Rebuild bei local-venv
req_file = d / "requirements.txt"
if (manifest or {}).get("execution") == "local-venv" and req_file.exists():
pip_packages = [l.strip() for l in req_file.read_text(encoding="utf-8").splitlines()
if l.strip() and not l.strip().startswith("#")]
venv = d / "venv"
if venv.exists():
shutil.rmtree(venv, ignore_errors=True)
try:
_setup_venv(d, pip_packages)
if manifest is not None:
manifest.pop("setup_error", None)
manifest["active"] = True
write_manifest(name, manifest)
except Exception as exc:
if manifest is not None:
manifest["active"] = False
manifest["setup_error"] = str(exc)[:500]
write_manifest(name, manifest)
logger.warning("Rollback %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
return {"ok": True, "name": name, "rolled_back_to": version_id,
"safety_snapshot": safety}
def delete_skill_version(name: str, version_id: str) -> dict:
"""Loescht eine einzelne Version aus versions/. Nicht-rueckholbar."""
archive = _versions_dir(name) / version_id
if not archive.exists():
raise ValueError(f"Version '{version_id}' nicht gefunden")
shutil.rmtree(archive)
manifest = read_manifest(name)
if manifest is not None:
manifest["version_history"] = [v for v in (manifest.get("version_history") or [])
if v.get("version_id") != version_id]
write_manifest(name, manifest)
return {"ok": True, "deleted": version_id}
# ─── Run ────────────────────────────────────────────────────────────
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
@@ -239,6 +635,22 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
env[f"ARG_{k.upper()}"] = str(v)
env["SKILL_DIR"] = str(d)
env["SHARED_UPLOADS"] = str(SHARED_UPLOADS)
# Brain-API fuer Skills die OAuth-Tokens / Brain-Helpers brauchen.
# Beispiel: requests.get(f"{os.environ['BRAIN_INTERNAL_URL']}/oauth/spotify/token")
env["BRAIN_INTERNAL_URL"] = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
# Config-Schema-Werte als CFG_<NAME>-ENV (P3). Default greift wenn Stefan
# noch keinen Wert gesetzt hat — None wird uebersprungen damit der Skill
# selbst entscheiden kann ob das ein Fehler ist.
schema = manifest.get("config_schema") or []
values = get_skill_config(name)
for field in schema:
fname = field.get("name")
if not fname:
continue
val = values.get(fname, field.get("default"))
if val is None:
continue
env[_config_env_name(fname)] = str(val)
# Command bauen
if exec_mode == "local-venv":
+81 -7
View File
@@ -25,7 +25,7 @@ import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht
# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt
# die Position als "unbekannt" und near() liefert False — verhindert
# Phantom-Fires basierend auf einer wochen-alten Position.
NEAR_MAX_AGE_SEC = 5 * 60
def _gps_state() -> dict[str, Any]:
"""Letzte bekannte Position aus /shared/state/location.json.
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
return int(time.time() - ts)
def collect_variables() -> dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
def _near_key(lat: float, lon: float, radius_m: float) -> str:
"""Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
State-Tracking pro Trigger pro Aufrufstelle."""
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
(für entered_near/left_near). Wird vom background-Loop reingegeben.
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
if prev_near_states is None:
prev_near_states = {}
new_near_states: Dict[str, bool] = {}
free_gb, free_pct = _disk_stats()
now = datetime.now()
gps = _gps_state()
@@ -176,12 +196,17 @@ def collect_variables() -> dict[str, Any]:
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
def _near(lat: float, lon: float, radius_m: float) -> bool:
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
veraltet betrachtet → False."""
cur_lat = vars_.get("current_lat")
cur_lon = vars_.get("current_lon")
if cur_lat is None or cur_lon is None:
return False
age = vars_.get("location_age_sec")
if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC:
return False
try:
R = 6371000.0
phi1 = math.radians(float(cur_lat))
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
except Exception:
return False
def _near(lat: float, lon: float, radius_m: float) -> bool:
"""True solange im Radius drin. Plus State-Tracking fuer
entered_near/left_near — wir merken uns das letzte Ergebnis
damit Uebergaenge erkannt werden koennen."""
current = _compute_near(lat, lon, radius_m)
new_near_states[_near_key(lat, lon, radius_m)] = current
return current
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return current and not prev
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
du am Parkplatz X was vergessen?' beim Verlassen."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return prev and not current
vars_["near"] = _near
vars_["entered_near"] = _entered_near
vars_["left_near"] = _left_near
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
vars_["_new_near_states"] = new_near_states
return vars_
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
{
"name": "near",
"signature": "near(lat, lon, radius_m)",
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
"Use-Case: 'bin noch in der Naehe von X?'. "
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
},
{
"name": "entered_near",
"signature": "entered_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
"Vorwarnung bevor man am Punkt ist.",
},
{
"name": "left_near",
"signature": "left_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
"vergessen?' beim Wegfahren.",
},
]
+820 -30
View File
File diff suppressed because it is too large Load Diff
+1072 -158
View File
File diff suppressed because it is too large Load Diff
+205 -18
View File
@@ -29,6 +29,40 @@ const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true";
const RVS_TOKEN = process.env.RVS_TOKEN || "";
const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456";
// ── Persistenz fuer agent_stream-Events ──────────────────
// Jeder agent_stream-Event wird parallel zum Broadcast in eine .jsonl
// geschrieben. Live-View laedt beim Tab-Oeffnen die letzten ~200 Zeilen,
// damit Browser-Reload / Standby den Verlauf nicht wegwerfen. Rotation
// haendelt logrotate / manual cleanup — wir cappen hier nur weichweich.
const AGENT_STREAM_LOG = process.env.AGENT_STREAM_LOG || "/shared/logs/agent_stream.jsonl";
const AGENT_STREAM_MAX_BYTES = 50 * 1024 * 1024; // 50 MB → halten den File handlebar
function appendAgentStream(payload) {
if (!payload || typeof payload !== "object") return;
try {
const line = JSON.stringify({ ts: Date.now(), ...payload }) + "\n";
// Soft-Cap: bei >50 MB ein Truncate auf den letzten ~25 MB Inhalt
try {
const st = fs.statSync(AGENT_STREAM_LOG);
if (st.size > AGENT_STREAM_MAX_BYTES) {
const half = Math.floor(AGENT_STREAM_MAX_BYTES / 2);
const fd = fs.openSync(AGENT_STREAM_LOG, "r");
const buf = Buffer.alloc(half);
fs.readSync(fd, buf, 0, half, st.size - half);
fs.closeSync(fd);
// bis zum naechsten Newline springen damit wir keine halbe Zeile haben
const firstNl = buf.indexOf(0x0a);
const start = firstNl >= 0 ? firstNl + 1 : 0;
fs.writeFileSync(AGENT_STREAM_LOG, buf.slice(start));
}
} catch {}
// Verzeichnis sicherstellen
try { fs.mkdirSync(path.dirname(AGENT_STREAM_LOG), { recursive: true }); } catch {}
fs.appendFileSync(AGENT_STREAM_LOG, line);
} catch (e) {
// Schweigend ignorieren — Persistence darf den Stream nicht blockieren
}
}
// ── State ───────────────────────────────────────────────
const state = {
gateway: { status: "disconnected", lastError: null, handshakeOk: false },
@@ -492,9 +526,10 @@ function handleGatewayMessage(msg) {
}
function sendToGateway(text, isTrace) {
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
log("error", "gateway", "Nicht verbunden — kann nicht senden");
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
return false;
}
@@ -632,6 +667,14 @@ function connectRVS(forcePlain) {
tool: msg.payload?.tool || msg.tool || "",
});
}
} else if (msg.type === "agent_stream") {
// Voller Live-Stream der Claude-Code-Session (assistant_text +
// tool_use mit Input + tool_result mit truncated Output). Geht
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
// Zusaetzlich persistieren damit Browser-Reload / Standby den
// History-Verlauf nicht wegwirft.
try { appendAgentStream(msg.payload); } catch {}
broadcast({ type: "agent_stream", payload: msg.payload });
} else if (msg.type === "memory_saved") {
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
const m = msg.payload || {};
@@ -695,8 +738,16 @@ function connectRVS(forcePlain) {
state.rvs.lastError = err.message;
broadcastState();
// TLS Fallback
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) {
// TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
// Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
// EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
// Log-Spam und doppelten Retries.
const netErr = (err.code || err.message || "").toString();
const isNetDown =
/^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
/EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
fallbackTriggered = true;
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
@@ -757,22 +808,20 @@ function sendToRVS_raw(msgObj) {
}
function sendToRVS(text, isTrace) {
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
// 1. An Gateway senden (damit ARIA antwortet)
const gatewayOk = sendToGateway(text, isTrace);
// 2. An RVS senden (damit die App die Nachricht sieht)
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
// den Text als User-Nachricht ans Brain weiterleitet und die App +
// Diagnostic die Bubble live spiegeln koennen.
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
if (isTrace) traceEnd(false, "RVS nicht verbunden");
return false;
}
sendToRVS_raw({
type: "chat",
payload: { text, sender: "diagnostic" },
timestamp: Date.now(),
});
return gatewayOk;
return true;
}
// ── Claude Proxy Test ────────────────────────────────────
@@ -1338,6 +1387,42 @@ const server = http.createServer((req, res) => {
else broadcast({ type: "agent_activity", activity: "idle" });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} else if (req.url.startsWith("/api/app-log") && req.method === "GET") {
// App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
// Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
// letzten 200 Eintraege (oder ?limit=N).
const url = new URL(req.url, "http://x");
const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
try {
const file = "/shared/logs/app.log";
if (!fs.existsSync(file)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 0, entries: [] }));
return;
}
const raw = fs.readFileSync(file, "utf-8");
const lines = raw.split("\n").filter(l => l.trim());
const tail = lines.slice(-limit);
const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: entries.length, entries }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
return;
} else if (req.url === "/api/app-log/clear" && req.method === "POST") {
// App-Log leeren — nach erfolgreichem Debug.
try {
const file = "/shared/logs/app.log";
if (fs.existsSync(file)) fs.unlinkSync(file);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/files-list" && req.method === "GET") {
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
@@ -1421,7 +1506,12 @@ const server = http.createServer((req, res) => {
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
}
});
req.on("close", () => { if (!zip.killed) zip.kill("SIGTERM"); });
// SIGTERM an zip nur wenn der Client wirklich disconnected
// (res.close vor res.end). req.on("close") feuert auch wenn
// der Request-Body durch ist — das wuerde zip vorzeitig killen.
res.on("close", () => {
if (!res.writableEnded && !zip.killed) zip.kill("SIGTERM");
});
});
return;
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
@@ -1666,6 +1756,68 @@ const server = http.createServer((req, res) => {
});
req.pipe(proxyReq);
return;
} else if (req.url.startsWith("/api/chat-backup") && req.method === "GET") {
// Tail des chat_backup.jsonl — fuer Debug-Sessions (was hat ARIA wirklich
// gesagt/getan). ?lines=N (Default 200, Max 5000).
try {
const u = new URL(req.url, "http://localhost");
const lines = Math.max(1, Math.min(5000, parseInt(u.searchParams.get("lines") || "200", 10) || 200));
const file = "/shared/config/chat_backup.jsonl";
let raw = "";
try { raw = fs.readFileSync(file, "utf-8"); } catch {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, lines: [] }));
}
const all = raw.split("\n").filter(l => l.trim());
const tail = all.slice(-lines);
const parsed = tail.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, count: parsed.length, total: all.length, lines: parsed }));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: e.message }));
}
} else if (req.url.startsWith("/api/agent-stream") && req.method === "GET") {
// Tail / paginierter Slice des persistierten agent_stream.jsonl.
// Modi:
// ?lines=N → letzte N Zeilen (Live-View Initial-Load)
// ?page=P&perPage=M → 1-indexed Pagination (Modal-Browser);
// page=1 = neueste Seite, hoehere Pages = aelter
try {
const u = new URL(req.url, "http://localhost");
const linesParam = u.searchParams.get("lines");
const pageParam = u.searchParams.get("page");
const perPageParam = u.searchParams.get("perPage");
const file = AGENT_STREAM_LOG;
let raw = "";
try { raw = fs.readFileSync(file, "utf-8"); } catch {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, file, total: 0, lines: [] }));
}
const all = raw.split("\n").filter(l => l.trim());
let slice, page = 1, perPage = 0, pagesTotal = 1;
if (pageParam || perPageParam) {
perPage = Math.max(10, Math.min(5000, parseInt(perPageParam || "100", 10) || 100));
pagesTotal = Math.max(1, Math.ceil(all.length / perPage));
page = Math.max(1, Math.min(pagesTotal, parseInt(pageParam || "1", 10) || 1));
// page=1 = juengste Seite → vom Ende her slicen
const end = all.length - (page - 1) * perPage;
const start = Math.max(0, end - perPage);
slice = all.slice(start, end);
} else {
const lines = Math.max(1, Math.min(5000, parseInt(linesParam || "200", 10) || 200));
slice = all.slice(-lines);
}
const parsed = slice.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({
ok: true, file, total: all.length, count: parsed.length,
page, perPage, pagesTotal, lines: parsed,
}));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: e.message }));
}
} else if (req.url === "/api/brain-export" && req.method === "GET") {
// Komplettes Gehirn als tar.gz streamen.
// Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.
@@ -1800,8 +1952,11 @@ wss.on("connection", (ws) => {
const msg = JSON.parse(raw.toString());
if (msg.action === "test_gateway") {
traceStart("Gateway", msg.text || "aria lebst du noch?");
sendToGateway(msg.text || "aria lebst du noch?", true);
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
// alte Browser-Sessions die noch den Button anzeigen nicht stumm
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
} else if (msg.action === "test_rvs") {
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
@@ -1849,6 +2004,18 @@ wss.on("connection", (ws) => {
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
} else if (msg.action === "aria_panic_stop") {
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
pendingMessageTime = 0;
watchdogWarned = false;
watchdogFixAttempted = false;
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
broadcast({ type: "agent_activity", activity: "idle" });
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
} else if (msg.action === "voice_upload") {
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
@@ -1907,6 +2074,26 @@ wss.on("connection", (ws) => {
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
}
// FLUX-Settings (Default-Modell + User-Keywords). flux-bridge nutzt
// fluxDefaultModel zum Hot-Swap, Brain liest die Keywords direkt aus
// /shared/config/voice_config.json fuer den System-Prompt.
if (msg.fluxDefaultModel !== undefined) {
voiceConfig.fluxDefaultModel = (msg.fluxDefaultModel === "schnell") ? "schnell" : "dev";
}
if (msg.fluxKeywordRaw !== undefined) {
voiceConfig.fluxKeywordRaw = String(msg.fluxKeywordRaw || "").trim().toLowerCase() || "flux";
}
if (msg.fluxKeywordSwitch !== undefined) {
voiceConfig.fluxKeywordSwitch = String(msg.fluxKeywordSwitch || "").trim().toLowerCase() || "fix";
}
// HuggingFace-Token fuer gated FLUX.1-dev. Wird per RVS an die
// flux-bridge gepusht, dort als HF_TOKEN env gesetzt vor dem
// naechsten from_pretrained. Leerer String = "kein Token" (statt
// 'behalt was du hattest'), damit Stefan ihn auch wieder loeschen
// kann.
if (msg.huggingfaceToken !== undefined) {
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
+22 -7
View File
@@ -12,13 +12,15 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
claude-max-api"
volumes:
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
- ./aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
@@ -65,11 +67,27 @@ services:
- QDRANT_PORT=6333
- PROXY_URL=http://proxy:3456
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
# Read-Timeout fuer den Proxy-Call. Hoch, weil Agent-Loops (Pentests
# etc.) auch eine Stunde+ dauern koennen. Der Proxy seinerseits hat
# einen Idle-Watchdog (Default 20min Inaktivitaet) der den Subprocess
# killt, der dann seinen close-Event sendet — Brain bekommt also
# immer was zurueck, auch bei wirklich haengenden Subprozessen.
# Connect/Write/Pool sind klein (10/30/10s) damit toter Proxy
# schnell erkannt wird (siehe proxy_client.py).
- PROXY_TIMEOUT_SEC=${PROXY_TIMEOUT_SEC:-86400}
# OAuth-Callback-URL Bestandteile. Brain baut daraus
# https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} als
# redirect_uri fuer Provider wie Spotify/Google/etc. RVS_PORT_PUBLIC
# ist der nach aussen exposed Port (= TLS-Port hinter Caddy/Nginx),
# nicht der interne RVS-Container-Port.
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true}
volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
- ./aria-data/ssh:/root/.ssh # SSH-Keys fuer aria-wohnung (geteilt mit Proxy)
- aria-shared:/shared # gleicher Austausch-Speicher wie Bridge
- ./aria-shared:/shared # gleicher Austausch-Speicher wie Bridge
restart: unless-stopped
networks:
- aria-net
@@ -85,7 +103,7 @@ services:
ports:
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
volumes:
- aria-shared:/shared # Shared Volume fuer Datei-Austausch
- ./aria-shared:/shared # Shared Volume fuer Datei-Austausch
# Audio-Zugriff
- /run/user/1000/pulse:/run/user/1000/pulse
- /dev/snd:/dev/snd
@@ -114,7 +132,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Container Restart + Brain-Export/Import
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
- aria-shared:/shared # Shared Volume (Uploads + Config + Voices)
- ./aria-shared:/shared # Shared Volume (Uploads + Config + Voices)
- ./aria-data/brain:/brain # Brain-Export/Import (tar.gz aus Bind-Mount)
environment:
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
@@ -127,9 +145,6 @@ services:
- RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped
volumes:
aria-shared: # Datei-Austausch zwischen Bridge / Brain / Diagnostic
networks:
aria-net:
driver: bridge
+180
View File
@@ -0,0 +1,180 @@
# FLUX.1-dev Bildgenerierung — Architektur & Stand
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**:
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
RVS verbindet und auf seinen Request-Typ lauscht.
## Pipeline
```
Stefan / App
│ Chat-Nachricht ("mal mir einen Sonnenuntergang ueberm Hangar")
▼
aria-bridge ── send_to_core ──▶ aria-brain
│ chooses tool: flux_generate(prompt=..., width=..., ...)
│ POST /internal/flux-generate
▼
aria-bridge (VM)
│ pushes {type: "flux_request",
│ payload: {requestId, prompt, ...}}
│ via RVS-Broadcast
▼
RVS
│ fanout
▼
flux-bridge (Gamebox)
│ FluxPipeline.from_pretrained(...)
│ pipeline(prompt, width, height, steps, guidance).images[0]
│ PIL → PNG → base64
│ {type: "flux_response", payload: {state:"done",
│ requestId, base64, mimeType, ...}}
▼
RVS
│
▼
aria-bridge (VM)
│ _pending_flux[requestId].set_result(payload)
│ base64-decode → /shared/uploads/aria_generated_<ts>.png
│ HTTP 200 zurueck an Brain mit {path, sizeBytes, ...}
▼
aria-brain
│ Tool-Result + Hint: "schreib [FILE: {path}] in deine Antwort"
│ Final-Reply: "Hier dein Bild:\n[FILE: /shared/uploads/aria_generated_<ts>.png]"
▼
aria-bridge
│ _FILE_MARKER_RE → file_from_aria-Event
│ Marker bleibt im Chat-Text fuer Hist; App rendert das Bild inline
▼
App + Diagnostic
```
## Komponenten
### 1. `flux/bridge.py` (neu) — flux-bridge Container
- `FluxPipeline` (diffusers) mit `enable_model_cpu_offload()` als Default,
damit FLUX.1-dev (~24 GB on disk, ~12 B params) auf einer RTX 3060
(12 GB VRAM) ueberhaupt laeuft.
- Lazy-Load: Modell wird beim ersten `flux_request` (oder im Initial-Load)
geladen, `service_status: "flux", state: "loading" | "ready" | "error"`
wird via RVS broadcastet → Diagnostic-Badge zeigt's an.
- Single-Worker-Queue (`_flux_queue`) — GPU darf nicht parallel rendern,
sonst OOM oder Crash.
- Progress-Ping: `flux_response {state: "rendering"}` direkt nach
Queue-Pickup, damit die aria-bridge weiss "Auftrag angekommen", auch
wenn der eigentliche Render 60s braucht.
- Caps:
- `width`/`height`: 256 .. `FLUX_MAX_DIM` (Default 1536), gesnappt auf
Vielfache von 64.
- `steps`: 1 .. `FLUX_MAX_STEPS` (Default 50).
- `guidance_scale`: 0.0 .. 20.0.
- `prompt`: max 2000 chars.
- Env-Switches:
- `FLUX_MODEL` — Default `black-forest-labs/FLUX.1-dev` (non-commercial).
Alt: `FLUX.1-schnell` (Apache-2.0, 4 Steps, deutlich schneller).
- `FLUX_OFFLOAD` — `model` (default), `sequential` (sparsamer, langsamer)
oder `none` (alles auf GPU; nur fuer >=24 GB VRAM-Karten).
- `FLUX_DTYPE` — `bfloat16` (default) oder `float16`.
- `HF_TOKEN` — FLUX.1-dev braucht HuggingFace-Login.
### 2. `flux/docker-compose.yml` — eigener Stack
Bewusst NICHT mit in `xtts/docker-compose.yml` gepackt: FLUX kann auch
separat laufen (z.B. spaeter auf einer 4090, waehrend die 3060 weiter
TTS+STT bedient). Eigener Compose, eigene `.env.example`, eigenes
`hf-cache/`-Volume.
- GPU-Reservation analog zu f5tts/whisper.
- Volume `./hf-cache:/root/.cache/huggingface` — wenn flux auf der
gleichen Maschine wie xtts laeuft kann man `../xtts/hf-cache`
symlinken, dann ist der Modell-Cache geteilt.
- Restart `unless-stopped`.
### 3. `rvs/server.js` — Allowlist erweitert
Neue Typen: `flux_request`, `flux_response` (auch wenn das Initial-Load-
broadcast `service_status` bereits zugelassen war).
### 4. `bridge/aria_bridge.py`
- `self._pending_flux: dict[str, asyncio.Future]` — request_id → future.
- `self._remote_flux_ready: bool` — wird von `service_status` Updates
gefuellt; steuert den HTTP-Timeout (240 s wenn ready, 900 s waehrend
des allerersten Modell-Downloads).
- `flux_response`-Handler: Progress-Ping (`state == "rendering"`) bleibt
no-op auf der Future; `state == "done"` setzt die Future, Error setzt
`{"error": ...}`.
- `_flux_generate(prompt, width, height, steps, guidance, seed)` — Helper:
1. UUID + Future
2. `flux_request` broadcasten
3. `asyncio.wait_for(future, timeout=...)`
4. base64 → `/shared/uploads/aria_generated_<ts>.png`
5. dict mit `{ok, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}`
- HTTP-Endpoint `POST /internal/flux-generate` im internen Listener
(Port 8090). Validiert prompt + clamps, ruft `_flux_generate`, gibt
Result als JSON zurueck.
### 5. `aria-brain/agent.py` — META-Tool `flux_generate`
```jsonc
{
"name": "flux_generate",
"parameters": {
"prompt": "string (englischer Prompt — FLUX liefert auf EN besser)",
"width": "integer (256..1536, default 1024)",
"height": "integer (256..1536, default 1024)",
"steps": "integer (1..50, default 28)",
"guidance_scale": "number (default 3.5)",
"seed": "integer (optional)"
}
}
```
Dispatcher:
- POSTet `{prompt, width, height, steps, guidance_scale, seed}` an
`http://aria-bridge:8090/internal/flux-generate` (urllib, 1200 s Timeout
— der erste Render kann den 24 GB Modell-Download triggern).
- Bei `ok=true` gibt das Tool den **Pfad** + Render-Stats zurueck und
weist Claude explizit an: *"Schreibe `[FILE: <path>]` in deine
Antwort an Stefan, dann zeigt die App das Bild inline."*
- Brain ueberlegt sich den Begleittext selber und packt den Marker an
passende Stelle.
### 6. `diagnostic/index.html` — Status-Badge
Label `flux: 'FLUX Image-Gen'` zum bestehenden `updateServiceStatus()`-Switch
hinzugefuegt — kein neuer Code, gleicher Banner-Mechanismus wie F5-TTS /
Whisper.
## File-Lifecycle
Generierte Bilder leben unter `/shared/uploads/aria_generated_<ts>.png`
(gleicher Folder wie User-Uploads). Damit:
- `[FILE: ...]`-Marker funktioniert (Bridge erlaubt nur Pfade unter
`/shared/uploads/`).
- File-Manager-Endpoints in Diagnostic (Liste/Loeschen/Zip) sehen sie
ohne Sonderbehandlung.
- Memory-Anhaenge: ARIA kann ein generiertes Bild im selben Turn an
einen Memory-Eintrag haengen (`memory_save(attach_paths=[path])`).
## Bekannte Stolpersteine
- **HF-Login**: FLUX.1-dev ist gated. Vor erstem Start `HF_TOKEN` im
`.env` setzen oder im Container `huggingface-cli login` machen, sonst
403 beim ersten Download.
- **Erster Render dauert lang**: 24 GB Modell laden + CUDA-Warmup → 5-10
min realistisch. Brain-HTTP-Timeout ist 1200 s, RVS-Future-Timeout
900 s (loading-Modus). Stefan sollte beim ersten "Mal mir was"-Request
ein bisschen Geduld haben — danach sind Renders ~30-90 s.
- **Lizenz**: FLUX.1-dev ist *non-commercial* (FLUX.1 Dev Non-Commercial
License). Fuer kommerzielle Nutzung muss man auf `FLUX.1-schnell`
(Apache-2.0) oder `FLUX.1-pro` (API only) wechseln. Stefan kann das
ueber `FLUX_MODEL` in der `.env` umstellen.
- **VRAM**: 12 GB (3060) reichen NUR mit `enable_model_cpu_offload`. Bei
Out-of-Memory in den Logs auf `FLUX_OFFLOAD=sequential` switchen
(deutlich langsamer, aber peak-VRAM ~6 GB).
- **Parallele Calls**: Single-Worker-Queue in der flux-bridge — ein
zweiter `flux_generate`-Tool-Call von Brain wartet, bis der erste fertig
ist. In der Praxis kein Problem, weil Stefan eh nicht zwei Bilder
gleichzeitig macht.
+36
View File
@@ -0,0 +1,36 @@
# ════════════════════════════════════════════════
# ARIA FLUX-Bridge — Konfiguration
# Kopieren nach .env und anpassen
# ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM / xtts/.env)
RVS_HOST=example.com
RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
RVS_TOKEN=dein_token_hier
# HuggingFace-Token + Default-Modell werden in ARIA Diagnostic verwaltet
# (Section "FLUX Bildgenerierung") und per RVS an die flux-bridge gepusht.
# Hier nichts noetig.
#
# Token-Pflicht NUR fuer FLUX.1-dev (gated). Workflow falls Du dev nutzen
# willst:
# 1) https://huggingface.co/black-forest-labs/FLUX.1-dev → "Agree"
# 2) https://huggingface.co/settings/tokens → "Read"-Token erzeugen
# 3) Token in Diagnostic > FLUX Bildgenerierung > HuggingFace-Token
# FLUX.1-schnell (Apache-2.0) laeuft ohne Token.
# Offloading-Strategie (VRAM-Steuerung):
# model — Default. Komponentenweise CPU-Offload, gut fuer 12 GB Karten.
# sequential — sparsamer (Peak ~6 GB), aber 2-3x langsamer.
# none — alles auf GPU. Nur fuer >= 24 GB VRAM-Karten.
FLUX_OFFLOAD=model
# Float-Type. bfloat16 ist FLUX-native; auf alten Karten ohne BF16-Support
# auf float16 wechseln.
FLUX_DTYPE=bfloat16
# Hard-Caps gegen versehentlich teure Renders
FLUX_MAX_STEPS=50
FLUX_MAX_DIM=1536
+5
View File
@@ -0,0 +1,5 @@
# HuggingFace Model-Cache (FLUX.1-dev ~24 GB on disk)
hf-cache/
# Docker .env
.env
+30
View File
@@ -0,0 +1,30 @@
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
# transitiv reingezogen) registriert in integrations/moe.py einen
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
# "Parameter input has unsupported type torch.Tensor" beim Import von
# diffusers.pipelines.flux.pipeline_flux.
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
RUN pip3 install --no-cache-dir \
torch==2.5.1 torchvision==0.20.1 \
--index-url https://download.pytorch.org/whl/cu121
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py .
CMD ["python3", "bridge.py"]
+557
View File
@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""
ARIA FLUX-Bridge — laeuft auf der Gamebox (RTX 3060).
Empfaengt flux_request via RVS → FLUX.1-dev/-schnell auf GPU → sendet
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
die Datei nach /shared/uploads/ und ARIA referenziert sie mit
[FILE: ...]-Marker in ihrer Antwort.
12 GB VRAM auf der 3060 reichen fuer FLUX.1-dev nur mit
`enable_model_cpu_offload()` — sonst OOM. Setze FLUX_OFFLOAD=sequential
fuer Maximal-Sparsamkeit (langsamer) oder FLUX_OFFLOAD=none wenn die
GPU genug VRAM hat (z.B. spaeter 4090).
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
FLUX_MODEL Default: black-forest-labs/FLUX.1-dev
Alt: black-forest-labs/FLUX.1-schnell (4-Step, Apache-2.0)
FLUX_DEVICE Default: cuda
FLUX_DTYPE Default: bfloat16 (alt: float16)
FLUX_OFFLOAD Default: model (alt: sequential | none)
FLUX_MAX_STEPS Default: 50
FLUX_MAX_DIM Default: 1536
"""
import asyncio
import base64
import io
import json
import logging
import os
import sys
import time
import uuid
from typing import Optional
import websockets
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("flux-bridge")
# HuggingFace/Torch download-Logs daempfen
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# Bootstrap-Fallback: nur relevant wenn beim allerersten Start KEIN
# Diagnostic-config-Broadcast eintrifft UND der erste Render-Request
# auch kein 'model' enthaelt. Default 'schnell', weil Apache-2.0
# (kein HF-Token noetig) — Stefan stellt sein gewuenschtes Default ueber
# Diagnostic ein. ENV ist also nur fuer den extremen Edge-Case da, in
# der .env.example absichtlich nicht mehr dokumentiert.
FLUX_MODEL = os.getenv("FLUX_MODEL", "black-forest-labs/FLUX.1-schnell").strip()
FLUX_DEVICE = os.getenv("FLUX_DEVICE", "cuda").strip()
FLUX_DTYPE = os.getenv("FLUX_DTYPE", "bfloat16").strip().lower()
FLUX_OFFLOAD = os.getenv("FLUX_OFFLOAD", "model").strip().lower()
FLUX_MAX_STEPS = int(os.getenv("FLUX_MAX_STEPS", "50"))
FLUX_MAX_DIM = int(os.getenv("FLUX_MAX_DIM", "1536"))
# FLUX-dev native: guidance=3.5, steps=28. FLUX-schnell: guidance=0.0, steps=4.
DEFAULT_STEPS_DEV = 28
DEFAULT_STEPS_SCHNELL = 4
DEFAULT_GUIDANCE_DEV = 3.5
DEFAULT_GUIDANCE_SCHNELL = 0.0
# Mapping fuer das User-facing Tag → HF-Modell-ID. Stefan stellt in Diagnostic
# nur 'dev' / 'schnell' ein; FLUX_MODEL aus der env kann zwar eine custom-ID
# sein (Bootstrap), wird aber beim ersten config-Broadcast normalerweise
# durch die Diagnostic-Wahl uebersteuert.
MODEL_TAGS: dict[str, str] = {
"dev": "black-forest-labs/FLUX.1-dev",
"schnell": "black-forest-labs/FLUX.1-schnell",
}
def _tag_to_model_id(tag: str) -> str:
"""Mappt 'dev'/'schnell' auf HF-ID. Andere Strings werden 1:1 durchgereicht
(custom-IDs aus FLUX_MODEL env). Leere/ungueltige Werte → FLUX_MODEL Default."""
if not tag:
return FLUX_MODEL
t = tag.strip()
return MODEL_TAGS.get(t, t)
def _is_schnell(model_id: str) -> bool:
return "schnell" in model_id.lower()
def _is_model_cached(model_id: str) -> bool:
"""Prueft ob ein HF-Modell-Snapshot lokal im hf-cache vorhanden ist.
HF speichert unter ~/.cache/huggingface/hub/models--{org}--{name}/snapshots/{rev}/.
Wenn das snapshots-Verzeichnis nicht existiert oder leer ist → Erst-Download
steht an (24+ GB fuer FLUX.1-dev, 24+ GB fuer FLUX.1-schnell — Stefan kriegt
dann nen Hinweis im Banner).
"""
if not model_id:
return False
cache_root = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
safe = "models--" + model_id.replace("/", "--")
snapshots = os.path.join(cache_root, "hub", safe, "snapshots")
if not os.path.isdir(snapshots):
return False
try:
for rev in os.listdir(snapshots):
rev_dir = os.path.join(snapshots, rev)
if os.path.isdir(rev_dir) and any(os.scandir(rev_dir)):
return True
except OSError:
return False
return False
def _torch_dtype():
"""Lazy-resolve damit Torch erst beim Modell-Laden importiert wird."""
import torch
return {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}\
.get(FLUX_DTYPE, torch.bfloat16)
def _snap_dim(v: int, default: int = 1024) -> int:
"""FLUX braucht Multiples von 16 (sicher: 64). Clamp + Snap."""
try:
n = int(v)
except (TypeError, ValueError):
n = default
n = max(256, min(FLUX_MAX_DIM, n))
# Auf naechstes Vielfaches von 64 abrunden
n = (n // 64) * 64
return max(256, n)
class FluxRunner:
"""Haelt EINE FLUX-Pipeline. Bei Modell-Wechsel wird die alte verworfen
und die neue geladen (~15-30 s aus HF-Cache, keine Re-Downloads).
Pro Request kann ein 'dev'/'schnell'-Tag mitkommen; ohne Angabe wird
`default_model_id` genommen (steht Bootstrap auf FLUX_MODEL, wird beim
ersten config-Broadcast von der aria-bridge auf die Diagnostic-Wahl
aktualisiert).
"""
def __init__(self) -> None:
self.pipe = None
self._lock = asyncio.Lock()
# Aktuell geladenes Modell — leer solange noch nix geladen wurde.
self.model_id: str = ""
# Was bei einem Request OHNE explizite model-Angabe benutzt wird.
# Wird durch Diagnostic-config gesetzt; FLUX_MODEL bleibt nur als
# Edge-Case-Fallback wenn weder Config noch Request einen Wert nennen.
self.default_model_id: str = FLUX_MODEL
self.last_load_seconds: float = 0.0
# True wenn der letzte _load_blocking einen Fresh-Download triggern
# musste (Modell war nicht im HF-Cache). Wird vom Caller geprueft
# und in den 'ready'-service_status als freshlyDownloaded gesetzt.
self.last_load_was_download: bool = False
def _load_blocking(self, model_id: str) -> None:
import torch
from diffusers import FluxPipeline
# Alte Pipeline freigeben damit der HF-Loader VRAM/RAM kriegt
if self.pipe is not None:
logger.info("Verwerfe alte Pipeline '%s'", self.model_id)
try:
del self.pipe
except Exception:
pass
self.pipe = None
try:
torch.cuda.empty_cache()
except Exception:
pass
import gc
gc.collect()
was_cached = _is_model_cached(model_id)
self.last_load_was_download = not was_cached
if not was_cached:
logger.warning("FLUX '%s' nicht im HF-Cache — Erst-Download steht bevor (kann 5-10 min dauern).",
model_id)
logger.info("Lade FLUX '%s' (dtype=%s, offload=%s, cached=%s)...",
model_id, FLUX_DTYPE, FLUX_OFFLOAD, was_cached)
t0 = time.time()
pipe = FluxPipeline.from_pretrained(model_id, torch_dtype=_torch_dtype())
if FLUX_OFFLOAD == "sequential":
pipe.enable_sequential_cpu_offload()
elif FLUX_OFFLOAD == "none":
pipe.to(FLUX_DEVICE)
else: # "model" — default, Sweet-Spot fuer 12 GB Karten
pipe.enable_model_cpu_offload()
# VAE-Tiling spart VRAM bei grossen Bildern (>1024)
try:
pipe.vae.enable_tiling()
except Exception:
pass
self.pipe = pipe
self.model_id = model_id
self.last_load_seconds = time.time() - t0
logger.info("FLUX '%s' geladen in %.1fs", model_id, self.last_load_seconds)
try:
torch.cuda.empty_cache()
except Exception:
pass
async def ensure_loaded(self, model_id: Optional[str] = None) -> bool:
"""Stellt sicher dass die richtige Pipeline geladen ist. Wenn ein
anderes Modell gewuenscht ist als gerade aktiv, wird geswappt.
Returns True wenn ein Swap/Load stattgefunden hat."""
target = model_id or self.default_model_id or FLUX_MODEL
async with self._lock:
if self.pipe is not None and self.model_id == target:
return False
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking, target)
return True
def _generate_blocking(self, prompt: str, width: int, height: int,
steps: int, guidance: float, seed: Optional[int]) -> bytes:
import torch
gen = None
if seed is not None and seed >= 0:
gen = torch.Generator(device=FLUX_DEVICE).manual_seed(int(seed))
logger.info("Render (%s): %dx%d, steps=%d, guidance=%.2f, seed=%s, prompt=%r",
self.model_id, width, height, steps, guidance, seed, prompt[:80])
out = self.pipe(
prompt=prompt,
width=width,
height=height,
num_inference_steps=steps,
guidance_scale=guidance,
generator=gen,
)
image = out.images[0]
buf = io.BytesIO()
image.save(buf, format="PNG", optimize=True)
png_bytes = buf.getvalue()
# VRAM zurueckgeben fuer den naechsten Render
try:
torch.cuda.empty_cache()
except Exception:
pass
return png_bytes
async def generate(self, prompt: str, width: int, height: int,
steps: int, guidance: float, seed: Optional[int],
model_id: Optional[str] = None) -> bytes:
await self.ensure_loaded(model_id)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._generate_blocking, prompt, width, height, steps, guidance, seed,
)
# ── Helpers ─────────────────────────────────────────────────
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
async def _broadcast_status(ws, state: str, **extra) -> None:
"""Sendet service_status fuer das Flux-Modul.
state: 'loading' | 'ready' | 'error'."""
payload = {"service": "flux", "state": state}
payload.update(extra)
await _send(ws, "service_status", payload)
# ── Flux-Request Queue ──────────────────────────────────────
# Eine GPU, ein Render gleichzeitig. Parallele Requests OOM-en sonst.
_flux_queue: "asyncio.Queue[tuple]" = asyncio.Queue()
def _resolve_request(payload: dict, runner: FluxRunner) -> tuple[str, int, int, int, float, Optional[int], str]:
"""Liest Felder aus dem flux_request payload + clampt auf Caps.
Returns (prompt, width, height, steps, guidance, seed, resolved_model_id).
"""
prompt = (payload.get("prompt") or "").strip()
if not prompt:
raise ValueError("prompt fehlt")
if len(prompt) > 2000:
prompt = prompt[:2000]
width = _snap_dim(payload.get("width", 1024))
height = _snap_dim(payload.get("height", 1024))
# Modell-Wahl: explizit per Request > runner.default_model_id > FLUX_MODEL.
req_model = (payload.get("model") or "").strip()
resolved_model_id = _tag_to_model_id(req_model) if req_model else (runner.default_model_id or FLUX_MODEL)
schnell = _is_schnell(resolved_model_id)
default_steps = DEFAULT_STEPS_SCHNELL if schnell else DEFAULT_STEPS_DEV
default_guidance = DEFAULT_GUIDANCE_SCHNELL if schnell else DEFAULT_GUIDANCE_DEV
try:
steps = int(payload.get("steps", default_steps))
except (TypeError, ValueError):
steps = default_steps
steps = max(1, min(FLUX_MAX_STEPS, steps))
try:
guidance = float(payload.get("guidance_scale", default_guidance))
except (TypeError, ValueError):
guidance = default_guidance
if not (0.0 <= guidance <= 20.0):
guidance = default_guidance
seed = payload.get("seed")
if seed is not None:
try:
seed = int(seed)
except (TypeError, ValueError):
seed = None
return prompt, width, height, steps, guidance, seed, resolved_model_id
async def _flux_worker(ws, runner: FluxRunner) -> None:
"""Serialisiert Renders — eine GPU, ein Bild gleichzeitig."""
while True:
payload = await _flux_queue.get()
request_id = payload.get("requestId") or str(uuid.uuid4())
try:
await _do_render(ws, runner, payload, request_id)
except Exception:
logger.exception("Flux-Worker Fehler")
await _send(ws, "flux_response", {
"requestId": request_id,
"error": "internal error",
})
finally:
_flux_queue.task_done()
async def _do_render(ws, runner: FluxRunner, payload: dict, request_id: str) -> None:
t0 = time.time()
try:
prompt, width, height, steps, guidance, seed, target_model_id = _resolve_request(payload, runner)
except ValueError as e:
logger.warning("flux_request invalid: %s", e)
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)})
return
# Modell-Swap noetig? Status broadcasten damit Diagnostic-Banner es zeigt.
swap_needed = (runner.pipe is None or runner.model_id != target_model_id)
will_download = swap_needed and not _is_model_cached(target_model_id)
if swap_needed:
await _broadcast_status(ws, "loading", model=target_model_id,
downloading=will_download)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "switching_model",
"model": target_model_id,
"downloading": will_download,
})
# Progress-Ping: User soll sehen dass was passiert (Render >30s realistisch)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "rendering",
"width": width, "height": height, "steps": steps,
"model": target_model_id,
})
try:
png = await runner.generate(prompt, width, height, steps, guidance, seed,
model_id=target_model_id)
except Exception as e:
logger.exception("FLUX Render-Fehler")
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)[:200]})
if swap_needed:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
if swap_needed:
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds,
freshlyDownloaded=runner.last_load_was_download)
dt = time.time() - t0
b64 = base64.b64encode(png).decode("ascii")
logger.info("Render fertig: %dx%d, %d KB PNG, %.1fs (%s)",
width, height, len(png) // 1024, dt, runner.model_id)
await _send(ws, "flux_response", {
"requestId": request_id,
"state": "done",
"base64": b64,
"mimeType": "image/png",
"width": width,
"height": height,
"steps": steps,
"guidance": guidance,
"seed": seed,
"model": runner.model_id,
"renderSeconds": round(dt, 2),
"sizeBytes": len(png),
})
# ── Haupt-Loop ──────────────────────────────────────────────
async def run_loop(runner: FluxRunner) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s", masked)
# max_size 100 MB damit ein 4 MP PNG (~5-10 MB → ~13 MB base64)
# locker reinpasst. Mit dem RVS-Limit (100 MB) konsistent.
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
max_size=100 * 1024 * 1024) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
async def _load_with_status():
"""Bei Connect KEIN Eager-Load — wir fragen erst die
Diagnostic-Config ab. Welches Modell tatsaechlich geladen
wird entscheidet sich entweder durch den config-Broadcast
(kommt direkt danach) oder durch den ersten flux_request.
Bis dahin gibt's keinen service_status, das Banner taucht
erst auf wenn wir wirklich was laden."""
try:
if runner.pipe is not None:
# Pipeline ueberlebt nur Container-Lifetime; hier
# also nur falls schon ein Modell aktiv ist (Reconnect).
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds)
logger.info("Initial: sende config_request an aria-bridge "
"(kein Eager-Load, warte auf Diagnostic-Wahl)")
await _send(ws, "config_request", {"service": "flux"})
except Exception as e:
logger.exception("Initial-Setup crashed: %s", e)
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
asyncio.create_task(_load_with_status())
worker = asyncio.create_task(_flux_worker(ws, runner))
async def _apply_default_change(new_tag: str):
"""Wechselt den Default. Wenn ein anderes Modell als aktuell
aktiv gewuenscht ist, wird eager geladen — der naechste
Render ist dann ohne Swap-Delay."""
new_model_id = _tag_to_model_id(new_tag)
runner.default_model_id = new_model_id
if runner.model_id == new_model_id:
logger.info("[config] Default-Modell bleibt: %s", new_model_id)
return
will_download = not _is_model_cached(new_model_id)
logger.info("[config] Default-Modell wechselt: %s → %s (download=%s)",
runner.model_id or "(none)", new_model_id, will_download)
try:
await _broadcast_status(ws, "loading", model=new_model_id,
downloading=will_download)
await runner.ensure_loaded(new_model_id)
await _broadcast_status(ws, "ready",
model=runner.model_id,
loadSeconds=runner.last_load_seconds,
freshlyDownloaded=runner.last_load_was_download)
except Exception as e:
logger.exception("Modell-Swap fehlgeschlagen")
try:
await _broadcast_status(ws, "error", error=str(e)[:200])
except Exception:
pass
try:
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {}
if mtype == "flux_request":
await _flux_queue.put(payload)
elif mtype == "config":
# Diagnostic-Broadcast (oder aria-bridge nach Reconnect).
# HuggingFace-Token MUSS vor dem Modell-Swap gesetzt sein,
# weil FluxPipeline.from_pretrained den Token aus der env
# liest. Reihenfolge im selben Tick gewaehrleistet das.
if "huggingfaceToken" in payload:
tok = (payload.get("huggingfaceToken") or "").strip()
if tok:
os.environ["HF_TOKEN"] = tok
os.environ["HUGGING_FACE_HUB_TOKEN"] = tok
logger.info("[config] HF-Token gesetzt (len=%d)", len(tok))
else:
os.environ.pop("HF_TOKEN", None)
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
logger.info("[config] HF-Token entfernt (leerer Wert)")
tag = (payload.get("fluxDefaultModel") or "").strip()
if tag:
asyncio.create_task(_apply_default_change(tag))
finally:
worker.cancel()
try:
await worker
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
async def main() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
sys.exit(1)
runner = FluxRunner()
await run_loop(runner)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)
+57
View File
@@ -0,0 +1,57 @@
# ════════════════════════════════════════════════
# ARIA FLUX-Bridge — Text-to-Image (GPU)
# Eigener Stack, weil FLUX auch auf einer anderen
# Maschine als f5tts/whisper laufen kann (z.B. 4090
# separat vom Gaming-PC). Verbindet sich selbst per
# WebSocket zum RVS und lauscht auf flux_request.
# ════════════════════════════════════════════════
#
# Voraussetzungen:
# - NVIDIA-GPU mit >= 12 GB VRAM (3060 reicht mit
# enable_model_cpu_offload). Bei < 12 GB:
# FLUX_OFFLOAD=sequential setzen, sonst OOM.
# - Docker mit NVIDIA Container Toolkit
# - HuggingFace-Token in .env (FLUX.1-dev ist gated)
# - .env mit RVS-Verbindungsdaten (gleiche wie xtts!)
#
# Start: docker compose up -d
# ════════════════════════════════════════════════
services:
# ─── FLUX Bildgenerierung (GPU) ─────────
# Empfaengt flux_request via RVS, rendert PNG mit FLUX (12B Params)
# und broadcastet flux_response mit base64-PNG zurueck. aria-bridge speichert
# die Datei nach /shared/uploads/ und ARIA referenziert sie via [FILE:]-Marker.
#
# Modell-Wahl + HuggingFace-Token werden in ARIA Diagnostic eingestellt
# ("FLUX Bildgenerierung") und per RVS gepusht — hier nichts noetig.
flux-bridge:
build: .
container_name: aria-flux-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
# Hardware-Bootstrap (Diagnostic-Settings uebersteuern alles andere
# zur Laufzeit — diese envs sind nur Edge-Case-Fallbacks).
- FLUX_DEVICE=${FLUX_DEVICE:-cuda}
- FLUX_DTYPE=${FLUX_DTYPE:-bfloat16}
- FLUX_OFFLOAD=${FLUX_OFFLOAD:-model}
- FLUX_MAX_STEPS=${FLUX_MAX_STEPS:-50}
- FLUX_MAX_DIM=${FLUX_MAX_DIM:-1536}
volumes:
- ./hf-cache:/root/.cache/huggingface # Bind-Mount. FLUX.1-dev ~24 GB on disk!
# Wenn flux auf der gleichen Maschine
# wie xtts laeuft: ../xtts/hf-cache
# symlinken um den Cache zu teilen.
restart: unless-stopped
+9
View File
@@ -0,0 +1,9 @@
diffusers>=0.30.0
transformers>=4.43.0
accelerate>=0.33.0
sentencepiece>=0.2.0
protobuf>=4.25.0
pillow>=10.0.0
huggingface_hub>=0.24.0
websockets>=12.0
numpy>=1.24
+68 -2
View File
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
### GPS-Trigger-Verbesserungen (entered_near + left_near + Timing-Fix)
- [x] **near() bei Auto-Vorbeifahrten verpasst — gefixt**: Background-Loop tickte alle 30s, Vorbeifahrt durch 300m-Radius bei 50-120 km/h dauert nur 18-43s → Tick konnte komplett dazwischen liegen. Fix: `TICK_SEC` 30 → 8 (Loop ist billig, Brain merkt das nicht). Plus event-getrieben: Bridge ruft nach jedem `location_update` ein POST `/triggers/check-now` im Brain → Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. Polling läuft parallel als Fallback für Watcher ohne GPS-Bezug
- [x] **near() Age-Schutz**: GPS-Daten älter als 5 Minuten (`NEAR_MAX_AGE_SEC=300`) gelten als veraltet → `near()` liefert False. Vorher hätte ein wochen-alter Wert die Funktion weiter als „in der Nähe" eingeordnet → Phantom-Fires wenn Tracking aus war
- [x] **Drei GPS-Modi statt einem**: `near()` bleibt = „solange drin". Neu: **`entered_near(lat, lon, r)`** feuert NUR beim Übergang außen→innen (Blitzer-Warner mit r=2000 = 2 km Vorwarnung, Ankunft mit r=100), **`left_near(lat, lon, r)`** feuert NUR beim Übergang innen→außen („Hast du am Parkplatz was vergessen?"). State-Tracking pro Trigger pro near-Aufruf (`near_states`-Dict im Manifest) — Background-Loop schreibt den letzten Auswertungswert immer zurück, damit beim nächsten Tick die Übergangs-Erkennung greift. ARIA's `trigger_watcher`-Tool-Description erklärt die drei Modi inkl. empfohlener Throttle-Werte (kurz für entered/left, lang für near)
### App-Memory-Editor + Crash-Reporting
- [x] **Bubble-Header dynamic** (created/updated/deleted): Die `🧠`-Bubble zeigt jetzt was passiert ist — "ARIA hat etwas gemerkt" / "Notiz geändert" / "Notiz gelöscht" (rot bei delete). Brain-Tools schicken `action`-Feld im memory_saved-Event mit
- [x] **Tap auf Memory-Bubble → Detail-Modal**: Komponente `MemoryDetailModal` zeigt alle Felder (Titel, Type, Category, Tags, voller Content, Anhang-Vorschau mit Thumbnails). Stift-Icon wechselt in Edit-Mode mit Form-Feldern + 📌 Pinned-Toggle. **Anhänge hoch-/runterladen + löschen** im Modal (DocumentPicker, multipart-Upload via RVS-Brain-Proxy). Memory komplett löschen mit Confirm
- [x] **Notizen-Inbox-Button (`🗂️`)** neben der Lupe in der Status-Leiste: Vollbild-Modal mit zwei Sections — „Aus diesem Chat" (kompakte Liste der Spezial-Bubbles aus dem aktuellen Verlauf, klickbar) + „Alle Memories aus der DB" mit dem `MemoryBrowser`. Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) werden im Chat-Stream gefiltert (statt unten zu kleben)
- [x] **Memory-Editor in App-Settings**: neue Sektion 🧠 „Gedächtnis" in den App-Einstellungen. Komplette CRUD-UI mit Wortlich-Suche, Type-Dropdown, Pinned/Cold-Filter, „+ Neu" anlegen. Selbe `MemoryBrowser`-Komponente wie in der Inbox
- [x] **RVS-Brain-Proxy als Fundament**: Bridge implementiert generischen `brain_request` / `brain_response`-Channel — die App kann beliebige Brain-HTTP-Endpoints via RVS adressieren (GET/POST/PATCH/DELETE, JSON+Base64-Body, base64-encoded Binär-Antworten). `services/brainApi.ts` als Promise-basierter Client mit Request-ID-Routing, Timeout, automatischem Listener-Setup
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary-Komponente fängt React-Render-Fehler, `installGlobalCrashReporter` haengt sich an `ErrorUtils.setGlobalHandler` + `HermesInternal.enablePromiseRejectionTracker`. Crashes wandern als `app_log`-Event durch RVS, Bridge schreibt JSONL in `/shared/logs/app.log`. Diagnostic-Server liefert GET `/api/app-log[?limit=N]` + POST `/api/app-log/clear`. **`tools/fetch-app-logs.sh`** holt die Logs auf die Dev-Maschine (über `ARIA_DIAG_URL` aus `.claude/aria-vm.env`), speichert in `.aria-debug/` (gitignored), zeigt Stack-Trace kompakt auf stdout
- [x] **`memory_search` + `memory_update` Tools**: ARIA kann die DB jetzt aktiv durchsuchen (Volltext/Semantic) und existierende Einträge per ID patchen statt fragmentierende neue anzulegen. Tool-Description sagt explizit „Memory ist Truth über Conversation-Window" — wenn der User korrigiert hat, gilt das was im Memory steht. Wichtig nach Diagnostic-Edits damit ARIA die neue Wahrheit sieht statt aus dem Window zu raten
- [x] **App-Bugfixes**: (a) URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt (`brainApi._qs()`). (b) Cache leer + Datei-Tap → Auto-Re-Download via file_request statt Toast-Sackgasse, plus State-Cleanup (uri/localUri auf undefined). (c) Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled auf FlatList + äußere ScrollView). (d) Modal-im-Modal auf Android gefixt — MemoryBrowser nimmt optionalen `onOpenMemory`-Callback, kein verschachteltes DetailModal mehr. (e) Alert.prompt (iOS-only) durch eigenes Text-Input-Modal ersetzt fuer „Neue Memory anlegen"
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
@@ -324,17 +341,66 @@ Skills mit Tool-Use.
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
- [x] **chat_backup ts war Container-Uptime statt UNIX-ms**: `_append_chat_backup` nutzte `asyncio.get_event_loop().time()` (Monotonic, bei jedem Restart wieder 0) statt `time.time()`. Folge: Server-Bubbles mit ts wie 394M (6 min Uptime) wurden in der App-History neben App-side Bubbles mit Date.now() (1.778e12) sortiert — Hello-Kitty-Konversation von gestern landete chronologisch nach heutigen Karten-Routen, neue Nachrichten verschwanden unter dem 500er-Cap. Plus: Doppelpost-Schutz griff nicht weil das 5-Min-ts-Fenster bei 1.7 Bio ms Diff nie zutraf. Fix: Bridge schreibt jetzt UNIX-ms, Migration-Script `tools/migrate_chat_backup_ts.py` repariert vorhandene jsonl (284/299 ts umgeschrieben auf der VM, Datei-Reihenfolge bleibt). App-Merge dedupt zusaetzlich per blossem Text-Match (ohne ts-Diff) — schuetzt auch gegen vorhandene lokale Duplikate
- [x] **User-Bubble ⏳→failed bei langsamen ARIA-Antworten**: ACK-Timer (30 s × 3 Retries) lief durch obwohl Brain laengst arbeitete — wenn `chat_ack` aus irgendwelchen Gruenden nicht durchkam (RVS-Frame verloren etc.), wurde die Bubble nach 90 s auf failed gesetzt obwohl die Antwort gleich danach kam. Fix: jedes `agent_activity != idle`-Event ist impliziter ACK — Brain wuerde nicht arbeiten wenn es die Nachricht nicht haette. Beim ersten non-idle Event werden alle laufenden ACK-Timer gecanceled und sending-Bubbles auf 'sent' gesetzt. ACK_TIMEOUT_MS zusaetzlich von 30 s auf 60 s als Backup
- [x] **Gedanken-Stream Modal scrollte nicht**: innerer `TouchableOpacity` (eigentlich nur fuer close-on-tap-outside-Schutz) hat alle Touch-Events konsumiert. Fix: durch `View` mit `onStartShouldSetResponder={true}` + `onResponderTerminationRequest={false}` ersetzt — blockt Tap-Propagation ohne Scrolls der Children zu verschlucken
### Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-Aggressivitaet
- [x] **Skill-Erstellung aggressiver als gewollt**: Prompt sagte „Harte Regel — IMMER Skill anlegen wenn pip-Library noetig". ARIA hat das wortwoertlich genommen und bei einer simplen pdf-extract-Frage sofort `skill_create` aufgerufen → Brain 12 Min blockiert (venv 2 min + pip install 10 min Timeout in `skills.py`). App zeigt „ARIA denkt", Bridge emitted nach 5 Min Timeout idle, User ohne Antwort. Fix in `prompts.py`: „Goldene Regel: NIE ungefragt Skills anlegen" + nur bei expliziter Anfrage („mach daraus einen Skill") und auch dann nur wenn die 4 Kriterien (wiederkehrend / nicht-trivial / parametrisierbar / wiederverwendbar) zutreffen. Greift auf der VM nach `docker compose restart aria-brain` ohne Re-Build
- [x] **Brain-Timeouts 5 Min → 20 Min**: drei verkettete 5-Min-Timeouts (Bridge `urlopen`, Brain `proxy_client`, Proxy `DEFAULT_TIMEOUT` im claude-max-api-proxy npm-Modul) feuerten exakt gleichzeitig. Live in den Logs nachvollzogen: ein Proxy-Call brauchte 4m51s und wurde von der Bridge auf den Sekundenbruchteil genau gekappt. Aufgabenstellungen wie Karten-Rekonstruktion mit 10+ curl-Calls oder PDF-Verarbeitung brauchen aber locker 8–15 Min. Fix: alle drei Timeouts auf 1200 s, plus dritter sed-Patch im docker-compose proxy-Service (`DEFAULT_TIMEOUT = 300000 → 1200000`). App-Stuck-Watchdog auf 1260 s (21 Min, knapp drueber)
- [x] **RVS-Block waehrend Brain-Call** (example.com:444 droppt nach 4 Min idle): `async for raw_message in ws: await _handle_rvs_message(...)` — das await blockierte den recv-Loop solange `send_to_core` lief. Die websockets-Lib beantwortete Pings im Hintergrund, aber der RVS-Server zaehlt nur echte App-Frames und droppt sonst die Verbindung. Symptom: App+Diagnostic zeigten „abgebrochen" obwohl Brain noch arbeitete. Fix: `send_to_core` als `asyncio.create_task` statt `await` — RVS-recv-Loop bleibt frei, neue Messages werden weiter verarbeitet, Verbindung bleibt lebendig
### Gedanken-Stream + Live-Tool-Events
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
### Such-Sprung-Praezision + Such-Reihenfolge
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
### Misc App-Polish
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
### Brain-Timeouts + Subprocess-Cleanup
- [x] **Brain-Timeout nach exakt 20min trotz aktiver ARIA**: `httpx.Client` im `proxy_client.py` hatte einen 1200s-Read-Timeout — der gleiche Wert den wir Tage zuvor am Proxy auf 24h hochgezogen hatten, aber im Brain uebersehen. Bei langen Pentests timed Brain raus obwohl der Proxy-Subprocess noch fleissig Events emittierte. Fix: `PROXY_TIMEOUT_SEC=86400` Env in der Compose, plus split-Timeouts in `httpx.Timeout(connect=10, read=86400, write=30, pool=10)` — toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen 24h laufen
- [x] **Verwaiste Claude-Subprocesses nach Brain-Disconnect**: `handleNonStreamingResponse` in `routes.js` hatte keinen `res.on("close")` (nur der Streaming-Branch). Wenn Brain die Verbindung gekappt hat (z.B. nach Timeout), lief der Claude-Subprocess weiter ohne dass noch jemand lauschte — Ressourcen-Leak. Fix: `res.on("close")` mit `isComplete`-Flag, Subprocess wird sofort gekillt bei Client-Disconnect
- [x] **Conversation-Inkonsistenz bei Brain-Exception**: `agent.chat()` fuegte den User-Turn ein BEVOR der Proxy-Call lief — bei Exception blieb der User-Turn ohne Assistant-Pair stehen, naechster Brain-Call sah `user → user` als letzte zwei Turns und konnte mit Tool-Calls fehlschlagen. Fix: try/except um den Tool-Loop, bei Exception wird ein Error-Marker (`[Fehler: ...]`) als Assistant-Turn geschrieben — Conversation bleibt konsistent
### OAuth-Pipeline (Spotify / Google / GitHub / Strava / Microsoft)
- [x] **Externe OAuth2-Provider per RVS-Callback**: ARIA brauchte Tokens fuer Spotify-Skill — bisher `redirect_uri=http://localhost:...` was vom Handy aus nicht erreichbar war, Stefan musste den Code manuell aus der URL kopieren (fragil, OAuth-Codes sind ~10min gueltig). Loesung: RVS-Server hat jetzt einen HTTP-Listener (selber Port wie WebSocket, hybrid via `http.createServer` + `wss.handleUpgrade`). Provider redirected an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet `oauth_callback`-Message → aria-bridge forwarded an Brain → Brain matched `state` (CSRF-Schutz), tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json` (file-mode 0600). Token-Refresh laeuft automatisch wenn <60s Restzeit
- [x] **Brain-Tools fuer ARIA**: `oauth_authorize(service, scopes?)` baut Auth-URL + speichert pending state, `oauth_get_token(service)` liefert aktuelles access_token (refresh wenn noetig), `oauth_revoke(service)` loescht. Skills nutzen diese statt selber Auth-Flow zu machen
- [x] **Generische Provider-Configs**: `DEFAULT_PROVIDERS` in `oauth.py` deckt Spotify, Google, GitHub, Strava, Microsoft mit ihren Quirks ab (Basic-Auth vs Body-Auth, Accept-Header fuer GitHub, `access_type=offline` fuer Google, etc.). Custom-Provider via `oauth_apps.json` moeglich
- [x] **Diagnostic-UI**: Einstellungen → OAuth-Apps. Pro Service Karte mit Status (verbunden/konfiguriert/leer), `client_id` + `client_secret` (Passwort-Toggle), Speichern + Autorisieren-Buttons. Autorisieren oeffnet Provider-Auth in neuem Tab; nach 8s Auto-Refresh
- [x] **Schoene Browser-Antwort vom RVS**: nach Callback bekommt der User eine Dark-Mode-HTML-Seite (✅ "OAuth erfolgreich, du kannst Tab schliessen — ARIA hat den Zugang erhalten") mit 4s Auto-Close — kein nackter JSON-Response
## Offen
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
- [ ] **Subprocess-Resume nach Kill/Timeout (Variante A — halb-automatisch)**: bei Idle-Timeout oder Brain-Disconnect ist die ARIA-Session weg (in-memory state des Claude-Code-Subprozesses, alle Tool-Outputs, Files-Reads). Stefan muss heute manuell *"weitermachen"* sagen, ARIA improvisiert aus dem Conversation-Window was sie noch weiss. Variante A: agent_stream-Events zusaetzlich in einer JSONL persistieren, beim naechsten Brain-Call die letzten N Events als „Resume-Context" in den System-Prompt einbauen — ARIA weiss dann konkret welche Tool-Calls zuletzt liefen und kann sauber fortsetzen. Aufwand ~1-2h. Nur angehen wenn die 24h-Timeouts (Commit 0887674) wirklich nochmal triggern
+531
View File
@@ -0,0 +1,531 @@
/**
* ARIA-patched API Route Handlers
*
* Erweiterung der npm-Version von claude-max-api-proxy:
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
* Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
* geht an ARIA_STREAM_HOOK_URL → Bridge → RVS `agent_stream` → Diagnostic
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml proxy-Block).
*/
import { v4 as uuidv4 } from "uuid";
import http from "http";
import { ClaudeSubprocess } from "../subprocess/manager.js";
import { openaiToCli } from "../adapter/openai-to-cli.js";
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity";
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-stream";
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
const TOOL_RESULT_MAX_CHARS = 4096;
const TOOL_INPUT_MAX_CHARS = 2048;
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
// 0 = deaktiviert (nicht empfohlen).
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
/**
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
*/
function _postJson(url, body) {
try {
const u = new URL(url);
const data = JSON.stringify(body);
const req = http.request({
method: "POST",
hostname: u.hostname,
port: u.port || 80,
path: u.pathname,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
timeout: 2000,
}, (res) => { res.resume(); });
req.on("error", () => {});
req.on("timeout", () => req.destroy());
req.write(data);
req.end();
} catch (_) { /* niemals weiterwerfen */ }
}
/**
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
*/
function _emitToolEvent(toolName) {
if (!toolName) return;
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
}
/**
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
}
function _truncate(str, max) {
if (typeof str !== "string") str = String(str ?? "");
if (str.length <= max) return { text: str, truncatedBytes: 0 };
return { text: str.slice(0, max), truncatedBytes: str.length - max };
}
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
const _activeSubprocesses = new Map();
function _trackSubprocess(requestId, subprocess) {
_activeSubprocesses.set(requestId, subprocess);
const cleanup = () => _activeSubprocesses.delete(requestId);
subprocess.on("close", cleanup);
subprocess.on("error", cleanup);
}
/**
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
* bei jedem Event reset, bei close/error/result gestoppt.
*
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
* gefeuert — wir muessen hier nichts extra emittieren.
*/
function _attachIdleWatchdog(subprocess, requestId) {
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
let timer = null;
let killed = false;
function _kill() {
if (killed) return;
killed = true;
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
try { subprocess.kill(); } catch (_) {}
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
}
function _reset() {
if (killed) return;
if (timer) clearTimeout(timer);
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
}
function _stop() {
if (timer) { clearTimeout(timer); timer = null; }
}
// Initial-Timer setzen
_reset();
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
subprocess.on("message", _reset);
subprocess.on("content_delta", _reset);
// Result/close/error → endgueltig stop
subprocess.on("result", _stop);
subprocess.on("close", _stop);
subprocess.on("error", _stop);
}
/**
* Hookt assistant + user Events und pusht beides an Bridge:
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
*/
function _attachToolHook(subprocess, requestId) {
subprocess.on("assistant", (message) => {
try {
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (!b) continue;
if (b.type === "tool_use") {
if (b.name) _emitToolEvent(b.name);
const inputStr = b.input ? JSON.stringify(b.input) : "";
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_use", {
id: b.id || null,
name: b.name || "",
input: inp.text,
inputTruncatedBytes: inp.truncatedBytes,
});
} else if (b.type === "text" && b.text) {
_emitStreamEvent(requestId, "text", { text: b.text });
} else if (b.type === "thinking" && b.thinking) {
// Wenn das Modell Extended Thinking emittiert — selten in
// Claude Code CLI, aber moeglich. Markieren wir extra.
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
}
}
} catch (_) { /* fail-open */ }
});
// tool_result Blocks kommen in user-Messages — die werden vom
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
// sondern nur ueber das generische 'message'-Event mit type:'user'.
// 'message' feuert auch fuer assistant/result — wir filtern auf user
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
// assistant-Handler oben).
subprocess.on("message", (message) => {
try {
if (message?.type !== "user") return;
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_result") {
let content = "";
if (typeof b.content === "string") content = b.content;
else if (Array.isArray(b.content)) {
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
}
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
_emitStreamEvent(requestId, "tool_result", {
id: b.tool_use_id || null,
content: out.text,
truncatedBytes: out.truncatedBytes,
isError: b.is_error === true,
});
}
}
} catch (_) { /* fail-open */ }
});
}
/**
* Handle POST /v1/chat/completions
*
* Main endpoint for chat requests, supports both streaming and non-streaming
*/
export async function handleChatCompletions(req, res) {
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
const body = req.body;
const stream = body.stream === true;
try {
// Validate request
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
res.status(400).json({
error: {
message: "messages is required and must be a non-empty array",
type: "invalid_request_error",
code: "invalid_messages",
},
});
return;
}
// Convert to CLI input format
const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess();
// ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
// Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
// Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
_attachToolHook(subprocess, requestId);
_trackSubprocess(requestId, subprocess);
_attachIdleWatchdog(subprocess, requestId);
_emitStreamEvent(requestId, "start", { model: body.model || null });
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
}
else {
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
}
}
catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[handleChatCompletions] Error:", message);
if (!res.headersSent) {
res.status(500).json({
error: {
message,
type: "server_error",
code: null,
},
});
}
}
}
/**
* Handle streaming response (SSE)
*
* IMPORTANT: The Express req.on("close") event fires when the request body
* is fully received, NOT when the client disconnects. For SSE connections,
* we use res.on("close") to detect actual client disconnection.
*/
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Request-Id", requestId);
// CRITICAL: Flush headers immediately to establish SSE connection
// Without this, headers are buffered and client times out waiting
res.flushHeaders();
// Send initial comment to confirm connection is alive
res.write(":ok\n\n");
return new Promise((resolve, reject) => {
let isFirst = true;
let lastModel = "claude-sonnet-4";
let isComplete = false;
// Handle actual client disconnect (response stream closed)
res.on("close", () => {
if (!isComplete) {
// Client disconnected before response completed - kill subprocess
subprocess.kill();
}
resolve();
});
// Handle streaming content deltas
subprocess.on("content_delta", (event) => {
const text = event.event.delta?.text || "";
if (text && !res.writableEnded) {
const chunk = {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: lastModel,
choices: [{
index: 0,
delta: {
role: isFirst ? "assistant" : undefined,
content: text,
},
finish_reason: null,
}],
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
isFirst = false;
}
});
// Handle final assistant message (for model name)
subprocess.on("assistant", (message) => {
lastModel = message.message.model;
});
subprocess.on("result", (_result) => {
isComplete = true;
if (!res.writableEnded) {
// Send final done chunk with finish_reason
const doneChunk = createDoneChunk(requestId, lastModel);
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
subprocess.on("error", (error) => {
console.error("[Streaming] Error:", error.message);
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({
error: { message: error.message, type: "server_error", code: null },
})}\n\n`);
res.end();
}
resolve();
});
subprocess.on("close", (code) => {
// Subprocess exited - ensure response is closed
if (!res.writableEnded) {
if (code !== 0 && !isComplete) {
// Abnormal exit without result - send error
res.write(`data: ${JSON.stringify({
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
})}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
// Start the subprocess
subprocess.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
}).catch((err) => {
console.error("[Streaming] Subprocess start error:", err);
reject(err);
});
});
}
/**
* Handle non-streaming response
*/
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
return new Promise((resolve) => {
let finalResult = null;
let isComplete = false;
// Client-Disconnect-Handler — wenn Brain die HTTP-Verbindung kappt
// (z.B. nach Read-Timeout), den noch laufenden Subprocess killen.
// Im Streaming-Branch existiert das schon; non-streaming hatte's
// bisher nicht → Subprozess lief verwaist weiter, Ressourcen-Leak.
res.on("close", () => {
if (!isComplete) {
console.warn("[NonStreaming] Client disconnected before result — killing subprocess", requestId);
try { subprocess.kill(); } catch (_) {}
}
resolve();
});
subprocess.on("result", (result) => {
finalResult = result;
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
isComplete = true;
if (!res.headersSent) {
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
}
resolve();
});
subprocess.on("close", (code) => {
isComplete = true;
if (res.writableEnded) {
// Client ist eh schon weg — nichts mehr zu senden.
resolve();
return;
}
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
}
else if (!res.headersSent) {
res.status(500).json({
error: {
message: `Claude CLI exited with code ${code} without response`,
type: "server_error",
code: null,
},
});
}
resolve();
});
// Start the subprocess
subprocess
.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
})
.catch((error) => {
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
resolve();
});
});
}
/**
* Handle GET /v1/models
*
* Returns available models
*/
export function handleModels(_req, res) {
res.json({
object: "list",
data: [
{
id: "claude-opus-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-sonnet-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-haiku-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
],
});
}
/**
* Handle GET /health
*
* Health check endpoint
*/
export function handleHealth(_req, res) {
res.json({
status: "ok",
provider: "claude-code-cli",
timestamp: new Date().toISOString(),
});
}
// ── Not-Aus Side-Channel ───────────────────────────────────
//
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
function _cancelAll() {
const ids = Array.from(_activeSubprocesses.keys());
let killed = 0;
for (const [id, subp] of _activeSubprocesses) {
try {
subp.kill();
killed++;
} catch (e) {
console.error("[aria-not-aus] kill failed for", id, e?.message);
}
}
_activeSubprocesses.clear();
return { killed, requestIds: ids };
}
try {
const internalServer = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/cancel-all") {
const result = _cancelAll();
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...result }));
return;
}
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
return;
}
res.writeHead(404).end();
});
internalServer.on("error", (err) => {
console.error("[aria-not-aus] internal listener error:", err.message);
});
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
});
} catch (e) {
console.error("[aria-not-aus] startup failed:", e?.message);
}
//# sourceMappingURL=routes.js.map
+14
View File
@@ -0,0 +1,14 @@
# ════════════════════════════════════════════════════════
# ARIA RVS — Server-seitige Config
# Kopieren nach .env und Werte eintragen.
# ════════════════════════════════════════════════════════
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
# WebSocket + HTTP weiter an den RVS-Container.
#
# WICHTIG:
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
PUBLIC_URL=rvs.example.de
+8
View File
@@ -0,0 +1,8 @@
# Docker-Compose Konfiguration mit echtem Domain-Namen
.env
# Caddy persistent state (Zertifikate, ACME-Account)
data/
# APK-Verzeichnis bleibt — wird ueber release.sh befuellt + commited als latest.apk
# (siehe Hauptverzeichnis README)
+45 -2
View File
@@ -1,10 +1,53 @@
# ════════════════════════════════════════════════════════
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
# Challenge ueber Port 80). OAuth-Provider wie Spotify
# verlangen HTTPS fuer non-localhost Redirect-URIs.
# ════════════════════════════════════════════════════════
#
# Voraussetzungen:
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
# - .env mit PUBLIC_URL gesetzt
#
# Start: docker compose up -d
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
# externer Caddy): caddy-service auskommentieren und
# rvs-Container den ports-Block geben (3000 → public Port).
services:
rvs:
build: .
ports:
- "${RVS_PORT:-443}:3000"
restart: always
# KEIN ports-Block — Caddy ist davor, RVS nur intern
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
volumes:
- ./updates:/updates # APK-Dateien fuer Auto-Update
environment:
- MAX_SESSIONS=10
networks:
- aria-rvs-net
# TLS-Terminator + Let's Encrypt. Holt automatisch ein Zertifikat
# fuer ${PUBLIC_URL} (HTTP-01 Challenge ueber Port 80). WebSocket-
# Upgrades und HTTP-Routes (OAuth-Callback) werden im reverse-proxy
# Modus automatisch durchgereicht. ACME-Cache liegt in ./data/caddy/
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
caddy:
image: caddy:latest
restart: always
ports:
- "80:80"
- "444:443"
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
volumes:
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
- ./data/caddy/config:/config # Caddy-Config-Cache
depends_on:
- rvs
networks:
- aria-rvs-net
networks:
aria-rvs-net:
+141 -7
View File
@@ -1,6 +1,7 @@
"use strict";
const { WebSocketServer } = require("ws");
const http = require("http");
const fs = require("fs");
const path = require("path");
@@ -31,13 +32,21 @@ const ALLOWED_TYPES = new Set([
"chat_history_request", "chat_history_response", "chat_cleared",
"delete_message_request", "chat_message_deleted",
"brain_request", "brain_response",
"app_log",
"file_delete_batch_request", "file_delete_batch_response",
"file_zip_request", "file_zip_response",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",
// Streaming-STT (Phase 1+2): App schickt PCM live an whisper-bridge,
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done",
"service_status",
"config_request",
"flux_request", "flux_response",
"agent_stream",
"oauth_callback",
]);
// Token-Raum: token -> { clients: Set<ws> }
@@ -68,20 +77,145 @@ function cleanupRooms() {
}
}
// ── WebSocket-Server starten ────────────────────────────────────────
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
//
// Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges,
// Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify,
// Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container
// (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus
// Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich}
// /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
// schoenen "Tab schliessen"-Seite.
//
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
// Default-Limit war der Killer fuer die voice_upload Pipeline.
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
const httpServer = http.createServer(handleHttpRequest);
const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 });
wss.on("listening", () => {
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
httpServer.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
httpServer.listen(PORT, () => {
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
// Beim Start pruefen ob eine APK da ist
const apkInfo = getLatestAPK();
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
});
// ── HTTP Route-Handler ──────────────────────────────────────────────
function handleHttpRequest(req, res) {
try {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const pathname = url.pathname;
// OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=...
// Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow.
// Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge,
// aber Bridge schon — sie picks-up und forwardet ans Brain).
const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/);
if (req.method === "GET" && oauthMatch) {
const service = oauthMatch[1];
const code = url.searchParams.get("code") || "";
const state = url.searchParams.get("state") || "";
const err = url.searchParams.get("error") || "";
const errDesc = url.searchParams.get("error_description") || "";
log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`);
const payload = { service, code, state };
if (err) {
payload.error = err;
if (errDesc) payload.errorDescription = errDesc;
}
// An alle Clients in allen Raeumen broadcasten — Bridge picks-up.
const msg = JSON.stringify({
type: "oauth_callback",
payload,
timestamp: Date.now(),
});
let receivers = 0;
for (const [, room] of rooms) {
for (const client of room.clients) {
if (client.readyState === 1) {
try { client.send(msg); receivers++; } catch (_) {}
}
}
}
log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`);
// Browser-Antwort: schoene HTML-Seite (auch bei Error)
const ok = !err;
const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen";
const bodyColor = ok ? "#34C759" : "#FF3B30";
const icon = ok ? "✅" : "❌";
const subtitle = ok
? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten."
: `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`;
const html = `<!doctype html>
<html lang="de"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} — ${escapeHtml(service)}</title>
<style>
html,body{margin:0;padding:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0D0D1A;color:#E0E0F0;}
body{display:flex;align-items:center;justify-content:center;}
.card{background:#1E1E2E;border:1px solid #2A2A3E;border-radius:12px;padding:32px;max-width:420px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,0.4);}
.icon{font-size:64px;line-height:1;margin-bottom:16px;}
.title{font-size:20px;font-weight:600;color:${bodyColor};margin-bottom:8px;}
.service{font-size:13px;color:#8888AA;margin-bottom:20px;text-transform:uppercase;letter-spacing:0.1em;}
.sub{font-size:14px;color:#C0C0D0;line-height:1.5;}
.hint{font-size:11px;color:#666680;margin-top:24px;}
</style></head><body>
<div class="card">
<div class="icon">${icon}</div>
<div class="title">${title}</div>
<div class="service">${escapeHtml(service)}</div>
<div class="sub">${subtitle}</div>
<div class="hint">Du kannst zur ARIA-App zurueckkehren.</div>
</div>
<script>setTimeout(()=>{try{window.close();}catch(e){}}, 4000);</script>
</body></html>`;
res.writeHead(ok ? 200 : 400, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(html);
return;
}
// Health-Endpoint
if (req.method === "GET" && pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, rooms: rooms.size }));
return;
}
// Default: 404
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found\n");
} catch (e) {
log(`HTTP handler error: ${e.message}`);
try { res.writeHead(500).end("Internal Server Error"); } catch (_) {}
}
}
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
wss.on("connection", (ws, req) => {
// Token aus URL-Query lesen: ws://host:port/?token=abc123
const url = new URL(req.url, `http://${req.headers.host}`);
+34
View File
@@ -0,0 +1,34 @@
# tools/
Hilfsskripte für die Dev-Maschine. Brauchen `.claude/aria-vm.env` (aus
`.example` kopieren + lokale VM-IP eintragen).
## fetch-app-logs.sh
Holt App-Crash-Logs von der VM und speichert sie unter `.aria-debug/`
(gitignored). Die App schickt JS-Errors und ungefangene Promise-
Rejections via RVS an die Bridge — Bridge sammelt in
`/shared/logs/app.log`, Diagnostic-Server gibt sie via
`GET /api/app-log` raus.
```bash
tools/fetch-app-logs.sh # 200 neueste Eintraege
tools/fetch-app-logs.sh --limit 50 # weniger
tools/fetch-app-logs.sh --watch # alle 5s pollen, neue rausgeben
tools/fetch-app-logs.sh --clear # nach Abholen Log auf VM leeren
```
Ausgabe enthaelt pro Eintrag: Uhrzeit, Level (error/warn/info), Scope
(z.B. `ChatScreen.InboxModal` oder `global-fatal`), Message, und die
ersten ~8 Stack-Frames. Die kompletten Daten liegen als JSON in
`.aria-debug/app-log-<timestamp>.json`.
Workflow nach einem Crash:
1. App rebuilden mit Crash-Reporting (passiert automatisch ab dem
`21a315c`-Commit)
2. Crash in der App ausloesen
3. `tools/fetch-app-logs.sh` auf der Dev-Maschine
4. Stacktrace lesen / Claude geben
5. Fix bauen
6. `tools/fetch-app-logs.sh --clear` damit der Log wieder sauber ist
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# fetch-app-logs.sh — App-Crash-Logs von der VM holen
#
# Nutzt .claude/aria-vm.env als Quelle fuer $ARIA_DIAG_URL und ruft
# GET /api/app-log?limit=N. Speichert die Roh-Response unter
# .aria-debug/app-log-<timestamp>.json und gibt eine kompakte
# Zusammenfassung auf stdout aus (letzte Eintraege mit Stack-Trace).
#
# Verwendung:
# tools/fetch-app-logs.sh # Default limit=200
# tools/fetch-app-logs.sh --limit 50 # nur 50 holen
# tools/fetch-app-logs.sh --clear # nach Abholen Log loeschen
# tools/fetch-app-logs.sh --watch # alle 5s pollen, neue Eintraege ausgeben
set -euo pipefail
LIMIT=200
CLEAR=0
WATCH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--limit) LIMIT="$2"; shift 2 ;;
--limit=*) LIMIT="${1#*=}"; shift ;;
--clear) CLEAR=1; shift ;;
--watch) WATCH=1; shift ;;
-h|--help)
sed -n '1,/^set/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
esac
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="$ROOT/.claude/aria-vm.env"
OUT_DIR="$ROOT/.aria-debug"
if [[ ! -f "$ENV_FILE" ]]; then
echo "FEHLER: $ENV_FILE nicht vorhanden. Aus .example kopieren und IP anpassen." >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if [[ -z "${ARIA_DIAG_URL:-}" ]]; then
echo "FEHLER: ARIA_DIAG_URL nicht gesetzt in $ENV_FILE" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
fetch_once() {
local ts json file
ts="$(date +%Y%m%d_%H%M%S)"
json="$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT")" || {
echo "FEHLER: curl gegen $ARIA_DIAG_URL fehlgeschlagen" >&2
return 1
}
file="$OUT_DIR/app-log-$ts.json"
echo "$json" > "$file"
python3 - "$file" <<'PY'
import json, sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text())
entries = data.get("entries") or []
print(f"=== {len(entries)} Eintrag{'e' if len(entries)!=1 else ''} (gespeichert unter {sys.argv[1]}) ===")
for e in entries[-20:]:
ts = e.get("ts") or 0
from datetime import datetime
when = datetime.fromtimestamp(ts/1000).strftime("%H:%M:%S") if ts else "?"
lvl = e.get("level","?")
scope = e.get("scope","?")
msg = (e.get("message") or "").splitlines()[0][:200]
print(f"\n[{when}] {lvl:5} {scope}: {msg}")
stack = (e.get("stack") or "").strip()
if stack:
for line in stack.splitlines()[:8]:
print(f" {line}")
if len(stack.splitlines()) > 8:
print(f" ... ({len(stack.splitlines())-8} weitere Zeilen — siehe JSON)")
PY
return 0
}
if [[ "$WATCH" == "1" ]]; then
echo "Watching $ARIA_DIAG_URL/api/app-log — Ctrl+C zum Beenden"
SEEN=""
while true; do
cur=$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT") || cur=""
hash=$(echo "$cur" | md5sum | awk '{print $1}')
if [[ "$hash" != "$SEEN" && -n "$cur" ]]; then
SEEN="$hash"
fetch_once
fi
sleep 5
done
else
fetch_once
fi
if [[ "$CLEAR" == "1" ]]; then
echo
echo "→ Log auf der VM leeren..."
curl -s --max-time 5 -X POST "${ARIA_DIAG_URL%/}/api/app-log/clear" | python3 -m json.tool || true
fi
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History.
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
werden unveraendert gelassen.
Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts
schon UNIX-ms und werden nicht angefasst.
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
"""
import json
import os
import shutil
import sys
import time
from pathlib import Path
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
def migrate(path: Path) -> None:
if not path.exists():
print(f"Datei nicht da: {path}")
sys.exit(1)
raw = path.read_text(encoding="utf-8").splitlines()
entries = []
for raw_line in raw:
s = raw_line.strip()
if not s:
continue
try:
entries.append(json.loads(s))
except Exception as e:
print(f" ueberspringe kaputte Zeile: {e}")
continue
if not entries:
print("Datei leer")
return
file_mtime_ms = int(os.path.getmtime(path) * 1000)
n = len(entries)
fixed = 0
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
# UNIX-ms bleiben unangetastet.
for i, entry in enumerate(entries):
ts = entry.get("ts", 0)
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
entry["ts"] = new_ts
fixed += 1
if fixed == 0:
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
return
# Backup
bak = path.with_suffix(path.suffix + ".bak")
shutil.copy2(path, bak)
print(f"Backup: {bak}")
# Atomic rewrite
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
for entry in entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
tmp.replace(path)
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
if __name__ == "__main__":
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
migrate(path)
+1 -1
View File
@@ -4,7 +4,7 @@
# ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM)
RVS_HOST=mobil.hacker-net.de
RVS_HOST=example.com
RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
+3
View File
@@ -2,6 +2,9 @@
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS/STT-Requests
#
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
# ════════════════════════════════════════════════
#
# Voraussetzungen:
+65
View File
@@ -375,6 +375,41 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
# ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log
#
# Gleiches Pattern wie in whisper-bridge: Stefan's Gamebox ist
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
#
# Toggle via aria-bridge config broadcast: f5ttsDebugLog (bool).
# ──────────────────────────────────────────────────────────────
_DEBUG_LOG_TO_BRIDGE: bool = False # default OFF — TTS-Renders sind teurer
# zu debuggen, normalerweise nicht noetig
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
"""Schickt einen app_log via RVS → /shared/logs/app.log mit platform='f5tts'.
No-op wenn Toggle aus."""
if not _DEBUG_LOG_TO_BRIDGE:
return
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "f5tts",
"level": level,
"scope": scope,
"message": str(message)[:2000],
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# ── Interne Transkription via whisper-bridge ────────────────
_pending_stt: dict[str, asyncio.Future] = {}
@@ -867,6 +902,30 @@ async def run_loop(runner: F5Runner) -> None:
else:
fut.set_result(payload.get("text") or "")
elif mtype == "config":
# Debug-Toggle (gleiche Semantik wie in whisper-bridge)
if "f5ttsDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("f5ttsDebugLog", False))
if old != _DEBUG_LOG_TO_BRIDGE:
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
# Last gasp wenn ausgeschaltet wird
if not _DEBUG_LOG_TO_BRIDGE:
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "f5tts",
"level": "info",
"scope": "config",
"message": "debug-log OFF (toggle aus)",
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
async def _update_with_status(p):
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
@@ -912,6 +971,12 @@ async def run_loop(runner: F5Runner) -> None:
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
# Sticky-Fallback verhindern: nach jedem Disconnect-Cycle wieder
# mit wss anfangen. Sonst klebt der Client nach einem temporaeren
# TLS-Hick auf ws:// fest und kommt nie mehr auf wss zurueck —
# genau das Problem das die App + Bridge frueher schon hatten.
use_tls = RVS_TLS
tls_fallback_tried = False
async def main() -> None:
+448 -25
View File
@@ -2,8 +2,19 @@
"""
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
Empfaengt stt_request via RVS → FFmpeg-Konvertierung → faster-whisper auf GPU
→ sendet stt_response zurueck an die aria-bridge.
Zwei Modi:
1) Legacy One-Shot: stt_request mit komplettem Audio (mp4/wav/ogg base64)
→ ffmpeg → faster-whisper → stt_response. Bleibt fuer Fallback/alte App.
2) Streaming + ML-Endpointer (neu): App schickt live PCM-Chunks waehrend
der Aufnahme. Bridge transkribiert alle ~700ms auf dem Ringbuffer und
feuert stt_endpoint sobald der Transkript-String N ms nicht mehr
waechst. Ersetzt dB/VAD-Stille — endpointet auf SEMANTISCHE Stille,
funktioniert im Auto / mit Musik im Hintergrund.
Erwartetes PCM-Format vom App-Native-Modul: 16 kHz mono s16le (genau
das was OpenWakeWord/AudioRecord schon liefert — kein Resampling).
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
@@ -21,6 +32,7 @@ import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
@@ -47,6 +59,13 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
class WhisperRunner:
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
@@ -55,6 +74,9 @@ class WhisperRunner:
self.model_size: str = WHISPER_MODEL
self.model: Optional[WhisperModel] = None
self._lock = asyncio.Lock()
# Serialisiert transcribe()-Calls — faster-whisper ist nicht
# parallel-safe auf einer GPU-Instanz, plus VRAM-Fragmentierung.
self._transcribe_lock = asyncio.Lock()
def _load_blocking(self, size: str) -> None:
logger.info(
@@ -78,19 +100,21 @@ class WhisperRunner:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_blocking, desired_size)
async def transcribe(self, audio: np.ndarray, language: str) -> tuple[str, float]:
async def transcribe(self, audio: np.ndarray, language: str,
beam_size: int = 5, vad_filter: bool = True) -> tuple[str, float]:
if self.model is None:
return "", 0.0
def _run():
segments, info = self.model.transcribe(
audio, language=language, beam_size=5, vad_filter=True,
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
)
text = " ".join(seg.text.strip() for seg in segments)
return text, info.duration
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _run)
async with self._transcribe_lock:
return await loop.run_in_executor(None, _run)
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
@@ -128,6 +152,14 @@ def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
pass
def pcm_s16le_to_float32(pcm_bytes: bytes) -> np.ndarray:
"""16-bit signed little-endian PCM → float32 in [-1, 1]. Whisper-Format."""
if not pcm_bytes:
return np.zeros(0, dtype=np.float32)
arr = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
return arr
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
@@ -139,14 +171,326 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
# ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log
#
# Stefan's Gamebox ist Windows, kein SSH → wir brauchen Whisper-Bridge-
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
# Logs-Tab zeigen sie dann mit platform="whisper".
#
# Toggle via aria-bridge config broadcast: whisperDebugLog (bool).
# Default ON solange wir Phase-1/2-Pipeline einfahren — danach
# defaultet aria-bridge ihn aus damit kein Spam.
# ──────────────────────────────────────────────────────────────
_DEBUG_LOG_TO_BRIDGE: bool = True
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
"""Schickt einen app_log via RVS → landet in /shared/logs/app.log mit
platform='whisper'. Idempotent: wenn Toggle aus → no-op."""
if not _DEBUG_LOG_TO_BRIDGE:
return
try:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "whisper",
"level": level,
"scope": scope,
"message": str(message)[:2000],
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
except Exception:
pass
# ──────────────────────────────────────────────────────────────
# STREAMING-SESSIONS
# ──────────────────────────────────────────────────────────────
@dataclass
class StreamSession:
"""State pro laufendem Streaming-STT-Request."""
request_id: str
audio_request_id: str
language: str
model: str
endpoint_ms: int
hard_cap_ms: int
voice: str = "" # echoed back via stt_endpoint fuer ChatScreen → TTS-Override
speed: float = 1.0
interrupted: bool = False # Barge-In
location: Optional[dict] = None
sample_rate: int = 16000
pcm_buffer: bytearray = field(default_factory=bytearray)
started_at: float = field(default_factory=time.time)
last_chunk_at: float = field(default_factory=time.time)
last_partial: str = ""
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern
class SessionManager:
"""Haelt alle aktiven Streaming-Sessions + Endpointer-Loop."""
def __init__(self, runner: WhisperRunner) -> None:
self.runner = runner
self._sessions: dict[str, StreamSession] = {}
self._ws = None # wird vom run_loop gesetzt
self._loop_task: Optional[asyncio.Task] = None
def attach_ws(self, ws) -> None:
self._ws = ws
def detach_ws(self) -> None:
self._ws = None
# Sessions ueberleben Disconnect — der naechste Reconnect kann sie weiter
# fuettern, falls die App das gleiche requestId nochmal schickt.
# Aber unsere App startet nach Reconnect eine neue Aufnahme; alte Sessions
# werden vom Cleanup-Task entsorgt nach STREAM_SESSION_TTL_S.
def start_session(self, payload: dict) -> Optional[StreamSession]:
request_id = payload.get("requestId", "").strip()
if not request_id:
logger.warning("stt_stream_start ohne requestId — ignoriert")
return None
if request_id in self._sessions:
logger.warning("stt_stream_start: requestId %s schon aktiv — alte Session wird ersetzt",
request_id[:8])
try:
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
except (TypeError, ValueError):
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
try:
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
except (TypeError, ValueError):
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
try:
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError):
speed = 1.0
session = StreamSession(
request_id=request_id,
audio_request_id=payload.get("audioRequestId", "") or "",
language=payload.get("language") or WHISPER_LANGUAGE,
model=payload.get("model") or self.runner.model_size or WHISPER_MODEL,
endpoint_ms=endpoint_ms,
hard_cap_ms=hard_cap_ms,
voice=payload.get("voice", "") or "",
speed=speed,
interrupted=bool(payload.get("interrupted", False)),
location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000),
)
self._sessions[request_id] = session
logger.info("Stream-Session offen: id=%s lang=%s model=%s endpointMs=%d hardCapMs=%d voice=%r",
request_id[:8], session.language, session.model,
session.endpoint_ms, session.hard_cap_ms, session.voice or "(default)")
return session
def feed_chunk(self, payload: dict) -> bool:
request_id = payload.get("requestId", "")
session = self._sessions.get(request_id)
if session is None or session.closed:
return False
pcm_b64 = payload.get("pcm", "")
if not pcm_b64:
return False
try:
pcm_bytes = base64.b64decode(pcm_b64)
except Exception:
logger.warning("Stream %s: ungueltige base64-PCM-Daten", request_id[:8])
return False
session.pcm_buffer.extend(pcm_bytes)
session.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> Optional[StreamSession]:
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
Final-Transcribe + Cleanup."""
session = self._sessions.get(request_id)
if session is None:
return None
session.closed = True
return session
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
async def run_endpointer(self) -> None:
"""Background-Loop: alle ~200ms ueber alle Sessions iterieren."""
logger.info("Endpointer-Loop gestartet (transcribe-interval=%dms, default-endpoint=%dms)",
STREAM_TRANSCRIBE_INTERVAL_MS, STREAM_DEFAULT_ENDPOINT_MS)
while True:
await asyncio.sleep(0.2)
now = time.time()
# Snapshot — sonst RuntimeError wenn wir waehrend Iteration sessions[]
# mutieren (Endpoint-Drop).
for sid, sess in list(self._sessions.items()):
try:
await self._tick_session(sess, now)
except Exception:
logger.exception("Endpointer-Tick crashed (session=%s)", sid[:8])
# Cleanup: tote Sessions (ohne Chunk seit STREAM_SESSION_TTL_S)
for sid, sess in list(self._sessions.items()):
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
logger.info("Stream %s: TTL ueberschritten (ohne Daten seit %.0fs) — drop",
sid[:8], now - sess.last_chunk_at)
self.drop(sid)
async def _tick_session(self, sess: StreamSession, now: float) -> None:
ws = self._ws
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
elapsed_ms = (now - sess.started_at) * 1000.0
if elapsed_ms > sess.hard_cap_ms and not sess.endpoint_sent and not sess.closed:
logger.info("Stream %s: HardCap %dms erreicht — forciere Endpoint",
sess.request_id[:8], sess.hard_cap_ms)
await self._finalize(sess, ws, reason="hardcap")
return
# Closed (stream_end empfangen) → finalisieren mit dem gesammelten Buffer
if sess.closed and not sess.endpoint_sent:
await self._finalize(sess, ws, reason="stream_end")
return
# Noch zu wenig Audio fuer eine erste Transkription
if audio_ms < STREAM_MIN_AUDIO_MS:
return
# Transcribe-Throttling
since_last = (now - sess.last_transcribe_at) * 1000.0
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
return
sess.last_transcribe_at = now
try:
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
except Exception:
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
return
try:
# Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz,
# nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt
# dann mit beam_size=5.
text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True)
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
text = text.strip()
grew = bool(text) and text != sess.last_partial
if grew:
sess.last_partial = text
sess.last_growth_at = now
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
await _send(ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
})
await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]} text={text[:80]!r}")
else:
# Stagnation pruefen — Endpoint-Bedingung
if sess.last_growth_at == 0.0:
# Noch gar kein Text erkannt. Wenn der User gar nichts sagt
# springt Brain irgendwann aus eigenem Conversation-Window-
# Timeout in der App raus; wir machen hier nix.
return
silence_ms = (now - sess.last_growth_at) * 1000.0
if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent:
logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r",
sess.request_id[:8], int(silence_ms), sess.last_partial[:80])
await self._finalize(sess, ws, reason="endpoint")
def _buffer_duration_ms(self, sess: StreamSession) -> float:
# 16-bit s16le mono → 2 bytes pro Sample
samples = len(sess.pcm_buffer) // 2
if samples == 0:
return 0.0
return (samples / sess.sample_rate) * 1000.0
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
feuert stt_endpoint + stt_stream_done, droppt Session."""
if sess.endpoint_sent:
return
sess.endpoint_sent = True
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
final_text = ""
stt_ms = 0
duration_s = 0.0
else:
t0 = time.time()
try:
final_text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=5, vad_filter=True)
except Exception:
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
final_text = sess.last_partial # fallback auf letzten Partial
stt_ms = int((time.time() - t0) * 1000)
duration_s = audio.size / 16000.0
final_text = final_text.strip()
logger.info("Stream %s: FINAL (reason=%s, %.1fs Audio, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
await _debug_log(ws, "stream.final",
f"id={sess.request_id[:12]} reason={reason} "
f"audio={duration_s:.1f}s stt={stt_ms}ms text={final_text[:80]!r}")
# stt_endpoint: das ist DAS Event auf das aria-bridge horcht fuer den
# Brain-Shortcut. Enthaelt alle Felder die bisher in 'audio' lagen,
# ohne den Audio-Roundtrip (App → aria-bridge → whisper → aria-bridge).
endpoint_payload = {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
"durationS": duration_s,
"sttMs": stt_ms,
"voice": sess.voice,
"speed": sess.speed,
"interrupted": sess.interrupted,
}
if sess.location:
endpoint_payload["location"] = sess.location
await _send(ws, "stt_endpoint", endpoint_payload)
# stt_stream_done: an die App — damit sie ihre Recording-State-Machine
# zurueck auf armed setzt (Mikro aus, ggf. Wake-Word wieder an).
await _send(ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": final_text,
"reason": reason,
})
self.drop(sess.request_id)
# ──────────────────────────────────────────────────────────────
# LEGACY ONE-SHOT (unveraendert)
# ──────────────────────────────────────────────────────────────
async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
request_id = payload.get("requestId", "")
audio_b64 = payload.get("audio", "")
mime_type = payload.get("mimeType", "audio/mp4")
# Modell-Auswahl:
# payload.model gesetzt → nimm das (aria-bridge sendet's basierend auf Config)
# sonst + Modell geladen → behalt das aktuelle (kein sinnloser Swap)
# sonst → fallback auf ENV-Default
model = payload.get("model") or (runner.model_size if runner.model is not None else WHISPER_MODEL)
language = payload.get("language") or WHISPER_LANGUAGE
@@ -156,8 +500,6 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
try:
t_load = time.time()
# Falls Modell noch nicht geladen (Race-Condition: stt_request vor config)
# → Status-Broadcast loading→ready damit der App-Banner aufpoppt
needs_load = runner.model is None or runner.model_size != model
if needs_load:
await _broadcast_status(ws, "loading", model=model)
@@ -205,7 +547,11 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
await _send(ws, "service_status", payload)
async def run_loop(runner: WhisperRunner) -> None:
# ──────────────────────────────────────────────────────────────
# WS-LOOP
# ──────────────────────────────────────────────────────────────
async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
@@ -216,20 +562,12 @@ async def run_loop(runner: WhisperRunner) -> None:
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s", masked)
# max_size 50MB damit grosse stt_request (Voice-Cloning-WAVs als
# base64 koennen mehrere MB werden) nicht das Frame-Limit sprengen
# und die Verbindung mit 1009 'message too big' killen.
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
sessions.attach_ws(ws)
# Initialer Status-Broadcast — uebertont alten "ready"-State
# im App/Diagnostic Banner (sonst denkt der User noch alles ist
# gut von vorher). Wenn Modell schon geladen → ready, sonst
# loading mit aktuellem (Default-)Namen.
# Plus: config_request an aria-bridge — wir wissen nicht ob
# sie auch grad reconnected hat oder schon laenger online ist.
async def _initial_handshake():
try:
if runner.model is not None:
@@ -241,6 +579,11 @@ async def run_loop(runner: WhisperRunner) -> None:
await _broadcast_status(ws, "loading", model=init_model)
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"})
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code
# ueberhaupt aktiv ist (Stefan baut auf Gamebox via PS,
# Build/Restart kann unbeabsichtigt alte Version weiterfahren).
await _debug_log(ws, "boot",
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
except Exception as e:
logger.exception("Initial-Handshake crashed: %s", e)
asyncio.create_task(_initial_handshake())
@@ -259,9 +602,84 @@ async def run_loop(runner: WhisperRunner) -> None:
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
asyncio.create_task(handle_stt_request(ws, payload, runner))
elif mtype == "stt_stream_start":
await _debug_log(ws, "stream.start",
f"received id={payload.get('requestId', '?')[:12]} "
f"audioReqId={payload.get('audioRequestId', '?')[:16]} "
f"endpointMs={payload.get('endpointMs')} "
f"hardCapMs={payload.get('hardCapMs')}")
# Ggf. Modell sicherstellen — sonst antwortet der erste
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
if needs_load:
async def _load_then_start(p, target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)
if not ok:
# Sehr verbose im Schlimmstfall — debug-Level reicht.
logger.debug("stt_audio_chunk: unbekannte/closed session %s",
payload.get("requestId", "")[:8])
await _debug_log(ws, "stream.chunk.reject",
f"unknown/closed session id={payload.get('requestId', '?')[:12]}",
level="warn")
else:
# Nur alle 25 Chunks loggen (=5s Audio) — sonst Spam.
try:
seq = int(payload.get("seq", 0) or 0)
if seq % 25 == 0:
await _debug_log(ws, "stream.chunk",
f"id={payload.get('requestId', '?')[:12]} seq={seq}")
except (TypeError, ValueError):
pass
elif mtype == "stt_stream_end":
req_id = payload.get("requestId", "")
logger.info("stt_stream_end empfangen: id=%s reason=%s",
req_id[:8], payload.get("reason", ""))
await _debug_log(ws, "stream.end",
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id)
elif mtype == "config":
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
# die Logs an/aus schalten kann.
if "whisperDebugLog" in payload:
global _DEBUG_LOG_TO_BRIDGE
old = _DEBUG_LOG_TO_BRIDGE
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("whisperDebugLog", False))
if old != _DEBUG_LOG_TO_BRIDGE:
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
# Last gasp wenn ausgeschaltet wird damit Stefan im Log sieht
# dass der Toggle griff.
if not _DEBUG_LOG_TO_BRIDGE:
await ws.send(json.dumps({
"type": "app_log",
"payload": {
"ts": int(time.time() * 1000),
"platform": "whisper",
"level": "info",
"scope": "config",
"message": "debug-log OFF (toggle aus)",
"stack": "",
},
"timestamp": int(time.time() * 1000),
}))
new_model = payload.get("whisperModel") or WHISPER_MODEL
# Laden wenn (a) noch nix geladen, oder (b) Modell wechselt
needs_load = (runner.model is None) or (new_model != runner.model_size)
if needs_load:
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
@@ -280,11 +698,10 @@ async def run_loop(runner: WhisperRunner) -> None:
await _broadcast_status(ws, "error", error=str(e)[:200])
asyncio.create_task(_swap_with_status(new_model))
else:
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
# ob stt_request ueberhaupt durch den RVS kommt
logger.debug("Unbeachteter Type: %s", mtype)
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
sessions.detach_ws()
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
use_tls = False
@@ -292,6 +709,8 @@ async def run_loop(runner: WhisperRunner) -> None:
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS
tls_fallback_tried = False
async def main() -> None:
@@ -299,7 +718,11 @@ async def main() -> None:
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
sys.exit(1)
runner = WhisperRunner()
await run_loop(runner)
sessions = SessionManager(runner)
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
# schlaeft solange das nicht gesetzt ist.
asyncio.create_task(sessions.run_endpointer())
await run_loop(runner, sessions)
if __name__ == "__main__":