Compare commits

...

19 Commits

Author SHA1 Message Date
duffyduck 20e623dc37 feat(app): Versions-Historie pro Datei im App-Datei-Manager
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
  - pro Datei via 🕒-Button die Versions-Liste anzeigen
  - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
  - per ⟲ eine alte Version als neue aktive setzen (non-destructive,
    macht im Backend einen 'restore:'-Commit, die bisherige Version
    bleibt in der Historie)

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

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

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

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

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

1. Dockerfile: apk add git

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

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

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

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

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

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

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

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

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

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

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

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

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

Diagnose aus dem Log-Trace:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loesungen:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Damit sehen wir im naechsten Test welcher Pfad gegriffen hat und ob
das Native-Modul ueberhaupt aufgerufen wurde.
2026-05-30 23:09:11 +02:00
15 changed files with 733 additions and 23 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh root@172.0.2.33 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10804
versionName "0.1.8.4"
versionCode 10900
versionName "0.1.9.0"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.8.4",
"version": "0.1.9.0",
"private": true,
"scripts": {
"android": "react-native run-android",
+237
View File
@@ -180,6 +180,14 @@ const SettingsScreen: React.FC = () => {
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
const [fileZipBusy, setFileZipBusy] = useState(false);
// Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git
// im diagnostic-Container. Browser-Variante davon laeuft schon, hier App-
// Side via RVS-Messages (file_version_list_request/...).
const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null);
const [versionsList, setVersionsList] = useState<Array<{hash: string; ts: number; subject: string; isCurrent?: boolean}>>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsError, setVersionsError] = useState('');
const versionDlPending = useRef<string | null>(null); // requestId beim Versions-Download
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -497,6 +505,117 @@ const SettingsScreen: React.FC = () => {
})();
}
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
// passt. Hier behandeln wir die Manager-initiierten Downloads
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
// ~/Download/ wie der ZIP-Pfad.
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage
if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
if (!b64) return;
const fileName = (p.name as string) ||
(p.serverPath as string || '').split('/').pop() ||
'aria-download';
(async () => {
try {
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
const dot = fileName.lastIndexOf('.');
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
const ext = dot > 0 ? fileName.slice(dot) : '';
target = `${dir}/${base} (${i})${ext}`;
i++;
}
await RNFS.writeFile(target, b64, 'base64');
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Versions-Liste einer Datei
if (message.type === ('file_version_list_response' as any)) {
const p: any = message.payload || {};
setVersionsLoading(false);
if (!p.ok) {
setVersionsError(p.error || 'Unbekannter Fehler');
setVersionsList([]);
} else {
setVersionsError('');
setVersionsList(p.versions || []);
}
}
// Datei-Manager: Versions-Inhalt (Download einer alten Version)
if (message.type === ('file_version_download_response' as any)) {
const p: any = message.payload || {};
if (p.requestId && p.requestId !== versionDlPending.current) return;
versionDlPending.current = null;
if (!p.ok) {
ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
// base64 → Downloads-Ordner. Hash als Suffix damit Original nicht
// ueberschrieben wird wenn beide Versionen nebeneinander vorliegen
// sollen.
(async () => {
try {
const baseName = (p.name as string) || 'aria-version';
const shortHash = (p.hash as string || '').slice(0, 7);
const dot = baseName.lastIndexOf('.');
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
const ext = dot > 0 ? baseName.slice(dot) : '';
const dir = RNFS.DownloadDirectoryPath;
let target = `${dir}/${stem}@${shortHash}${ext}`;
let i = 1;
while (await RNFS.exists(target)) {
target = `${dir}/${stem}@${shortHash}_${i}${ext}`;
i++;
}
await RNFS.writeFile(target, p.base64, 'base64');
const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Restore-Bestaetigung
if (message.type === ('file_version_restore_response' as any)) {
const p: any = message.payload || {};
if (!p.ok) {
ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT);
// Versions-Liste neu laden damit der neue restore-Commit auftaucht
if (versionsOpen) {
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: versionsOpen.path });
}
// File-Liste auch refreshen (mtime hat sich geaendert)
if (fileManagerOpen) {
setFileManagerLoading(true);
rvs.send('file_list_request' as any, {});
}
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
@@ -921,6 +1040,20 @@ const SettingsScreen: React.FC = () => {
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
// path-relativ-zu-uploads = nur der Dateiname,
// weil der File-Manager-Bereich flach ist
setVersionsOpen({name: f.name, path: f.name});
setVersionsList([]);
setVersionsError('');
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: f.name });
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>🕒</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
Alert.alert(
@@ -948,6 +1081,110 @@ const SettingsScreen: React.FC = () => {
})()}
</View>
</Modal>
{/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */}
<Modal
visible={versionsOpen !== null}
transparent
animationType="fade"
onRequestClose={() => setVersionsOpen(null)}
>
<TouchableOpacity
style={{flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'center', alignItems:'center'}}
activeOpacity={1}
onPress={() => setVersionsOpen(null)}
>
<TouchableOpacity
activeOpacity={1}
onPress={() => {}}
style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}}
>
<View style={{padding:12, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center'}}>
<Text style={{color:'#E0E0F0', fontSize:13, fontWeight:'bold', flex:1}} numberOfLines={1}>
Versionen {versionsOpen?.name || ''}
</Text>
<TouchableOpacity onPress={() => setVersionsOpen(null)} style={{padding:6}}>
<Text style={{color:'#888', fontSize:14}}></Text>
</TouchableOpacity>
</View>
<ScrollView style={{maxHeight:'85%'}} contentContainerStyle={{padding:8}}>
{versionsLoading && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>Lade...</Text>
)}
{!!versionsError && (
<Text style={{color:'#FF6B6B', padding:20}}>{versionsError}</Text>
)}
{!versionsLoading && !versionsError && versionsList.length === 0 && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>
Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index).
</Text>
)}
{versionsList.map(v => (
<View key={v.hash} style={{padding:10, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center', gap:8}}>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center', gap:6}}>
{v.isCurrent && (
<View style={{backgroundColor:'#34C75922', paddingHorizontal:6, paddingVertical:1, borderRadius:3}}>
<Text style={{color:'#34C759', fontSize:9}}>AKTIV</Text>
</View>
)}
<Text style={{color:'#0096FF', fontSize:11, fontFamily:'monospace'}}>
{v.hash.slice(0,7)}
</Text>
<Text style={{color:'#888', fontSize:11, flex:1}} numberOfLines={1}>
{v.subject || ''}
</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{new Date(v.ts).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000);
versionDlPending.current = reqId;
rvs.send('file_version_download_request' as any, {
path: versionsOpen.path,
hash: v.hash,
requestId: reqId,
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}}
>
<Text style={{color:'#0096FF', fontSize:11}}></Text>
</TouchableOpacity>
{!v.isCurrent && (
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
Alert.alert(
'Version aktiv setzen?',
`Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Restore', onPress: () => {
rvs.send('file_version_restore_request' as any, {
path: versionsOpen.path,
hash: v.hash,
});
ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT);
}},
],
);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}}
>
<Text style={{color:'#fff', fontSize:11}}></Text>
</TouchableOpacity>
)}
</View>
))}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
+44 -6
View File
@@ -341,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);
@@ -416,24 +429,34 @@ class AudioService {
private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'_releaseFocusDeferred SKIPPED (conversation active)')).catch(()=>{});
this._cancelDeferredFocusRelease();
return;
}
this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
import('./logger').then(m => m.reportAppDebug('audio.focus',
`_releaseFocusDeferred scheduled in ${this.FOCUS_RELEASE_DELAY_MS}ms`)).catch(()=>{});
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
if (this._conversationFocusActive) {
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'release timer fired but conversation now active → SKIP')).catch(()=>{});
return;
}
console.log('[Audio] AudioFocus jetzt released');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{});
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(() => {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS);
@@ -1368,12 +1391,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;
@@ -1504,6 +1528,20 @@ class AudioService {
this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId;
}
// AudioFocus EXPLIZIT fuer TTS halten — sonst pausiert Spotify zwar
// beim Recording-requestExclusive, der wird aber 800ms nach STT-Endpoint
// released (Brain-Processing-Gap), und wenn dann TTS startet ist niemand
// mehr Focus-Owner. Spotify pausiert evtl. implizit beim AudioTrack-
// USAGE_ASSISTANT, aber unsere nachtraegliche release+nudge-Sequenz
// kann es dann nicht zuverlaessig wieder anstossen. Mit explizitem
// requestDuck IST Spotify sauber-via-Focus pausiert, und der Release
// beim PcmPlaybackFinished triggert das normale "Owner fertig → resume"-
// Pattern in Spotify — funktioniert versionsunabhaengig.
// Pending Release-Timer canceln damit der nicht mitten in der TTS feuert.
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
import('./logger').then(m => m.reportAppDebug('audio.focus',
'TTS-start: requestDuck() called + canceled pending release')).catch(()=>{});
this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
});
+33 -3
View File
@@ -344,21 +344,51 @@ class WakeWordService {
/** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'.
*
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* ist, und unseren frisch re-armierten Listener killen.
*/
async endConversation(): Promise<void> {
if (this.state !== 'conversing') return;
if (this.state !== 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
return;
}
const wasBarge = this.bargeListening;
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
// den Listener stoppen.
this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
// als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try {
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed');
return;
} catch (err) {
} 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');
}
+9 -1
View File
@@ -940,11 +940,19 @@ class Agent:
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists
# liefert ~34 KB compact, das wurde hier auf 8 KB
# zugeschnitten und ARIA glaubte die Liste sei
# abgeschnitten obwohl der Skill alles korrekt
# paginiert hatte. Claude-Context vertraegt locker
# 50 KB pro Tool-Result.
messages.append(ProxyMessage(
role="tool",
tool_call_id=tc["id"],
name=tc["name"],
content=tool_result[:8000],
content=tool_result[:50000],
))
continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply
+129
View File
@@ -602,6 +602,135 @@ SEED_RULES: List[dict] = [
"'API Key' im Auth-Kapitel). Nicht raten."
),
},
{
"migration_key": "seed/voice/tts-voice-tag",
"type": "rule",
"title": "TTS-sprechbar: `<voice>...</voice>`-Tag fuer Antworten mit Einheiten/Zahlen/Markdown",
"category": "voice",
"content": (
"Die App spielt jede ARIA-Antwort als TTS ab. Der Brain-Bridge "
"filtert Markdown raus (Sternchen, Code-Bloecke, URLs), kennt "
"aber keine Einheiten-/Zahlen-Konvention — der Sprecher liest "
"dann '15 kt' als 'fuenfzehn k t' und '23,5°C' als 'dreiund-"
"zwanzig komma fuenf grad c'. Klingt scheisse.\n"
"\n"
"LOESUNG: Wenn deine Antwort eine der folgenden Eigenschaften hat, "
"haenge einen `<voice>...</voice>`-Block ans ENDE der Antwort. "
"Was DRIN steht ersetzt komplett den TTS-Text — Markdown im "
"Chat-Display bleibt unangetastet, gesprochen wird ausschliess-"
"lich die <voice>-Variante.\n"
"\n"
"WANN <voice>-Tag setzen:\n"
" - Einheiten-Abkuerzungen: kt, kg, km/h, °C, hPa, mbar, mph, "
" psi, dB, GB, MB, kWh, mAh ...\n"
" - Zahlen mit Komma (23,5 → 'dreiundzwanzig komma fuenf')\n"
" - Uhrzeiten mit Minuten (8:42 → 'acht Uhr zweiundvierzig')\n"
" - Wettervorhersagen / Statusberichte mit mehreren Daten\n"
" - Tabellen oder Listen mit Werten\n"
" - Lange Zahlen / IDs / Codes ('spotify:playlist:abc' nicht "
" vorlesen)\n"
" - Code-Bloecke (sollte ARIA in Sprache eh nicht zitieren)\n"
"\n"
"WANN NICHT (Overhead vermeiden):\n"
" - Kurze Statussaetze ('OK', 'mach ich', 'klar', 'spielt')\n"
" - Reine Prosa ohne Zahlen oder Einheiten\n"
" - Antworten unter 15 Worten ohne komplexes Element\n"
"\n"
"FORMAT:\n"
" Erst die Chat-Display-Variante (mit Markdown OK), dann an einer "
" neuen Zeile der <voice>-Block:\n"
"\n"
" Antwort-Text mit **Markdown**, Zahlen, Einheiten\n"
" <voice>Antwort-Text fuer den Lautsprecher, ausgeschrieben</voice>\n"
"\n"
"BEISPIEL Wetter:\n"
" **Wetter Berlin:** 23,5°C, Wind 15 kt aus NW, Druck 1018 hPa.\n"
" <voice>Das Wetter in Berlin: dreiundzwanzig Grad fuenf, "
" Wind mit fuenfzehn Knoten aus Nordwest, Luftdruck "
" tausendachtzehn Hektopascal.</voice>\n"
"\n"
"BEISPIEL Uhrzeit:\n"
" Stefan, dein Termin ist um **8:42** — noch 25 Minuten.\n"
" <voice>Stefan, dein Termin ist um acht Uhr zweiundvierzig. "
" Du hast noch fuenfundzwanzig Minuten.</voice>\n"
"\n"
"BEISPIEL Akku/Speicher:\n"
" Server: 87% Last, 12,4 GB RAM frei, Uptime 142h.\n"
" <voice>Server bei siebenundachtzig Prozent Last, zwoelf "
" Komma vier Gigabyte RAM frei, Laufzeit hundertzweiundvierzig "
" Stunden.</voice>\n"
"\n"
"BEISPIEL Multi-Track (NICHT vorlesen was nicht sprechbar ist):\n"
" Spielt jetzt: **Firestarter** (3:47) auf duffy-desktop.\n"
" <voice>Spielt jetzt Firestarter, drei Minuten siebenund-"
" vierzig.</voice> ← Device weglassen, war im Chat zur Info, "
" fuer Stefan akustisch redundant\n"
"\n"
"Der Voice-Tag wird automatisch aus Chat-Bubble und Chat-Backup "
"gestrippt — Stefan sieht NUR die Markdown-Variante in der App. "
"Voice-Text geht ausschliesslich an F5-TTS. Beide Welten happy.\n"
"\n"
"Sicherheitsnetz: wenn Du den Tag mal vergisst, faellt clean_text_"
"for_tts auf die alte Regex-Cleanup-Pipeline zurueck (Markdown weg, "
"Uhrzeiten teilweise ausgeschrieben). Aber 'kt' wird dann literal "
"vorgelesen. Also: lieber Tag setzen wenn unsicher."
),
},
{
"migration_key": "seed/skill-rule/list-api-pagination-snapshot",
"type": "rule",
"title": "Listen-API: einmal vollstaendig laden, DANN entscheiden",
"category": "verhalten",
"content": (
"Wenn ein Tool-Resultat ein Pagination-Schema hat (limit/offset/"
"next oder total > limit): ALLE Seiten in EINEM Tool-Call holen, "
"in EINEM Snapshot durchsuchen, ERST DANN handeln.\n"
"\n"
"Antipattern (31.05.2026, Stefan reproduziert mit 'Playlist Prodigy "
"raussuchen'):\n"
" - run_spotify path=/v1/me/playlists?limit=50\n"
"'nicht dabei'\n"
" - run_spotify path=/v1/me/playlists?limit=50&offset=50\n"
"'gefunden, ID=X' (46 Tracks)\n"
" - run_spotify path=/v1/me/player/play body={context_uri: ...:X}\n"
" → spielt aber FALSCHE Playlist\n"
" - Neue Suche, wieder paginiert → drittes Match ID=Y (15 Tracks)\n"
" - Insgesamt drei verschiedene IDs fuer dieselbe gesuchte Playlist\n"
" generiert, am Ende die falsche gespielt.\n"
"\n"
"Wurzel: Spotify sortiert /v1/me/playlists nach recently-played. "
"Zwischen aufeinanderfolgenden paginierten Calls AENDERT SICH die "
"Reihenfolge wenn parallel was abgespielt wird. Teilresultate aus "
"verschiedenen Calls vergleichen → inkonsistent.\n"
"\n"
"Richtig fuer Spotify (seit 31.05.2026 unterstuetzt):\n"
" run_spotify path=/v1/me/playlists?limit=50&_all=true\n"
" → Skill paginiert intern, liefert {items, total, fetched_count}.\n"
" → In items[] suchen, EINE ID waehlen, sofort handeln.\n"
" → Match-Logik: bevorzugt exakter Name (case-insensitive). "
"Wenn mehrere Substring-Matches: explizit nachfragen statt raten.\n"
"\n"
"Wann _all=true sinnvoll:\n"
" - /v1/me/playlists (alle eigenen Playlists)\n"
" - /v1/playlists/{id}/tracks (alle Tracks einer Playlist)\n"
" - /v1/me/tracks (Liked Songs)\n"
" - /v1/search?type=playlist&q=... (Such-Ergebnisse mit next)\n"
" - Andere Endpunkte mit items+next-Schema.\n"
"\n"
"Wann NICHT _all=true:\n"
" - /v1/me/player/currently-playing (kein Listen-Endpunkt)\n"
" - /v1/me/player/devices (kurze Liste, kein next)\n"
" - Wenn Du explizit nur 'die ersten 10' willst.\n"
"\n"
"Fuer andere Skills (yt-dlp, andere APIs) die noch kein _all "
"unterstuetzen: manuell paginieren bis total erreicht, ALLES in "
"EINEM mentalen Snapshot mergen, NIEMALS auf Teilresultaten "
"Entscheidungen treffen. Wenn zwei Pagination-Runs unterschiedliche "
"Matches liefern: ehrlich melden ('zwei verschiedene Playlists "
"namens X gefunden — welche meinst Du?') statt sich auf eine "
"festzulegen."
),
},
]
+21 -5
View File
@@ -683,8 +683,13 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
timed_out = True
duration = time.time() - t0
# Log schreiben (gekuerzt damit es nicht explodiert)
record = {
# Log auf der Disk wird gekuerzt (8000 chars) — sonst sammeln sich
# logs/*.json mit MBs an grossen Skill-Outputs an. Der Return-Value
# an den Caller (Agent) bekommt aber den vollen Output, dort wird
# nochmal in agent.py auf 50000 gecappt. Stefan-Fall: spotify-Skill
# mit _all=true liefert 50+ KB JSON, das hier wurde vorher auf 8 KB
# gekappt → ARIA sah immer nur den Anfang der Liste.
log_record = {
"ts": _now(),
"args": args or {},
"exit_code": exit_code,
@@ -694,7 +699,7 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
"timed_out": timed_out,
}
try:
log_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
log_path.write_text(json.dumps(log_record, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
@@ -703,8 +708,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
manifest["use_count"] = int(manifest.get("use_count", 0)) + 1
write_manifest(name, manifest)
record["ok"] = exit_code == 0
record["log_path"] = str(log_path)
# Return-Value: nicht kuerzen (Agent kuerzt downstream selbst). Nur
# die Disk-Log-Variante war beschnitten.
record = {
"ts": log_record["ts"],
"args": log_record["args"],
"exit_code": exit_code,
"duration_sec": log_record["duration_sec"],
"stdout": out_text or "",
"stderr": err_text or "",
"timed_out": timed_out,
"ok": exit_code == 0,
"log_path": str(log_path),
}
return record
+158 -4
View File
@@ -208,6 +208,30 @@ _UNIT_WORDS = [
]
def strip_voice_tag_for_display(text: str) -> str:
"""Entfernt `<voice>...</voice>`-Bloecke aus dem Chat-Display-Text.
ARIA kann einen <voice>-Block ANHAENGEN um eine TTS-freundliche Variante
ihrer Antwort zu liefern (Zahlen ausgeschrieben, Einheiten als Wort,
Markdown entfernt). Der Block wird dann von clean_text_for_tts als
TTS-Quelle benutzt — fuer die Chat-Bubble in der App soll er aber NICHT
sichtbar sein, sonst sieht Stefan literal '<voice>...' in seinem Chat.
Beispiel-Input (Stefan-typisch fuer Wetterbericht):
'**Wetter:** 23,5°C, Wind 15 kt NW\\n<voice>Wetter: dreiundzwanzig
komma fuenf Grad, Wind fuenfzehn Knoten Nordwest.</voice>'
Output:
'**Wetter:** 23,5°C, Wind 15 kt NW'
Mehrere Voice-Bloecke werden alle entfernt (ARIA koennte theoretisch
mehrere setzen, machen wir robust). Trailing-Whitespace nach dem Block
auch wegtrimmen.
"""
if not text or "<voice>" not in text.lower():
return text
return _re_tts.sub(r'\s*<voice>[\s\S]*?</voice>\s*', '\n', text, flags=_re_tts.IGNORECASE).strip()
def clean_text_for_tts(text: str) -> str:
"""Bereitet Chat-Text fuer Sprachausgabe auf.
@@ -1150,11 +1174,15 @@ class ARIABridge:
f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker)
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker
# UND ohne <voice>-Tag — der ist eine TTS-Annotation, gehoert nicht in
# die Chat-Historie weil ARIA ihre eigene Vorgaenger-Antwort sonst mit
# Voice-Tag-Noise als Kontext sieht).
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
display_text = strip_voice_tag_for_display(text)
assistant_backup_ts = self._append_chat_backup({
"role": "assistant",
"text": text,
"text": display_text,
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
})
@@ -1181,11 +1209,14 @@ class ARIABridge:
# TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional)
tts_text_preview = clean_text_for_tts(text)
# Antwort an die App weiterleiten (als Chat-Nachricht)
# Antwort an die App weiterleiten (als Chat-Nachricht).
# display_text == text aber ohne <voice>-Tag — der lebt nur transient
# in `text` damit clean_text_for_tts weiter unten daraus die TTS-
# Variante zieht. Im Chat-Bubble soll der Tag nicht erscheinen.
await self._send_to_rvs({
"type": "chat",
"payload": {
"text": text,
"text": display_text,
"sender": "aria",
"messageId": message_id,
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
@@ -2374,6 +2405,129 @@ class ARIABridge:
logger.warning("[rvs] file_delete_request: %s", e)
return
elif msg_type == "file_version_list_request":
# Versions-Historie einer Datei (App-Side Dateimanager).
# Pfad ist relativ-zu-/shared/uploads, kommt vom App-File-Manager
# der eh nur diesen flachen Bereich anzeigt. Diagnostic hat die
# git-Logik — wir proxien.
req_path = payload.get("path", "")
logger.info("[rvs] file_version_list_request: %s", req_path)
try:
qs = urllib.parse.urlencode({"path": req_path})
req = urllib.request.Request(
f"http://localhost:3001/api/files-versions?{qs}",
method="GET",
)
def _do_list():
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8", errors="ignore"))
except Exception as e:
return {"ok": False, "error": str(e)}
d = await asyncio.get_event_loop().run_in_executor(None, _do_list)
await self._send_to_rvs({
"type": "file_version_list_response",
"payload": d,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_list_request: %s", e)
return
elif msg_type == "file_version_download_request":
# Inhalt einer alten Version holen, base64 zurueck. Diagnostic
# liefert Binary, wir wrappen als base64 in der Response damit
# die App's RVS-WS damit umgehen kann.
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
req_id = payload.get("requestId", "")
logger.info("[rvs] file_version_download_request: %s @ %s",
req_path, req_hash[:7])
try:
qs = urllib.parse.urlencode({"path": req_path, "hash": req_hash})
req = urllib.request.Request(
f"http://localhost:3001/api/files-version-content?{qs}",
method="GET",
)
def _do_dl():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e:
return None, str(e).encode("utf-8")
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_dl)
if status == 200 and isinstance(body, (bytes, bytearray)):
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": True,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"base64": base64.b64encode(body).decode("ascii"),
"size": len(body),
"name": (req_path.rsplit("/", 1)[-1] or "file"),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
else:
err = body.decode("utf-8", "ignore") if isinstance(body, (bytes, bytearray)) else str(body)
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": False,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"error": f"HTTP {status}: {err[:200]}",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_download_request: %s", e)
return
elif msg_type == "file_version_restore_request":
# Eine Version als neue aktive setzen — non-destructive
# (diagnostic schreibt den alten Inhalt + macht einen neuen Commit).
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
logger.warning("[rvs] file_version_restore_request: %s <- %s",
req_path, req_hash[:7])
try:
body_bytes = json.dumps({"path": req_path, "hash": req_hash}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:3001/api/files-version-restore",
data=body_bytes,
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_restore():
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restore)
try:
parsed = json.loads(body) if body else {"ok": False, "error": "leer"}
except Exception:
parsed = {"ok": False, "error": body[:200]}
if status != 200 and "ok" not in parsed:
parsed = {"ok": False, "error": f"HTTP {status}"}
await self._send_to_rvs({
"type": "file_version_restore_response",
"payload": parsed,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_restore_request: %s", e)
return
elif msg_type == "location_update":
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
# /shared/state/location.json geschrieben, damit Watcher-Trigger
+2 -1
View File
@@ -1,7 +1,8 @@
FROM node:22-alpine
WORKDIR /app
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
RUN apk add --no-cache zip
# git fuer Auto-Versionierung von /shared/uploads/ (siehe server.js)
RUN apk add --no-cache zip git
COPY package.json ./
RUN npm install --production
COPY . .
+83
View File
@@ -4039,11 +4039,83 @@
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
</div>
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button>
<button class="btn secondary" onclick="showVersions('${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;" title="Versionen">🕒</button>
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
</div>`;
}).join('');
}
// ── Versions-Modal ──────────────────────────────────────
async function showVersions(fileName) {
// path-relative-to-/shared/uploads ist hier == fileName, weil unser
// file-Manager-Verzeichnis flach ist
const rel = fileName;
const modal = document.getElementById('versions-modal');
const title = document.getElementById('versions-title');
const body = document.getElementById('versions-body');
title.textContent = `Versionen — ${fileName}`;
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Lade...</div>';
modal.style.display = 'flex';
modal.dataset.path = rel;
try {
const r = await fetch('/api/files-versions?path=' + encodeURIComponent(rel));
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
if (!d.versions.length) {
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Noch keine Versions-Historie (Datei kommt erst nach naechstem Auto-Commit in den Index).</div>';
return;
}
const fmtDate = (ms) => new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
body.innerHTML = d.versions.map(v => {
const isCur = v.isCurrent
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">AKTIV</span>'
: '';
const subjShort = (v.subject || '').slice(0, 60);
return `<div style="padding:10px;border-bottom:1px solid #1E1E2E;display:flex;gap:8px;align-items:center;">
<div style="flex:1;min-width:0;">
<div style="color:#E0E0F0;font-size:12px;">${isCur}<code style="color:#0096FF;">${v.hash.slice(0,7)}</code> · ${escapeHtml(subjShort)}</div>
<div style="color:#555570;font-size:10px;">${fmtDate(v.ts)}</div>
</div>
<button class="btn secondary" onclick="downloadVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;">⬇ Download</button>
${v.isCurrent ? '' : `<button class="btn" onclick="restoreVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;background:#0096FF;color:#fff;">⟲ Restore</button>`}
</div>`;
}).join('');
} catch (e) {
body.innerHTML = `<div style="color:#FF6B6B;padding:20px;">${escapeHtml(e.message)}</div>`;
}
}
function closeVersionsModal() {
document.getElementById('versions-modal').style.display = 'none';
}
function downloadVersion(rel, hash) {
const url = '/api/files-version-content?path=' + encodeURIComponent(rel) + '&hash=' + encodeURIComponent(hash);
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a); a.click();
setTimeout(() => a.remove(), 100);
}
async function restoreVersion(rel, hash) {
if (!confirm(`Diese Version (${hash.slice(0,7)}) als aktive Version setzen?\n\nDie aktuelle Version bleibt rollback-bar in der Historie.`)) return;
try {
const r = await fetch('/api/files-version-restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: rel, hash }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
// Modal neu laden mit aktualisierter Liste
showVersions(rel);
loadFiles();
} catch (e) {
alert('Restore fehlgeschlagen: ' + e.message);
}
}
async function downloadSelected() {
const paths = [...filesSelected];
if (!paths.length) return;
@@ -5612,5 +5684,16 @@
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
loadAriaStreamHistory();
</script>
<!-- Versions-Modal fuer Datei-Manager -->
<div id="versions-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:1000;align-items:center;justify-content:center;" onclick="if(event.target===this)closeVersionsModal()">
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:8px;width:90%;max-width:600px;max-height:80vh;display:flex;flex-direction:column;">
<div style="padding:12px 16px;border-bottom:1px solid #1E1E2E;display:flex;align-items:center;gap:8px;">
<strong id="versions-title" style="color:#E0E0F0;flex:1;font-size:13px;">Versionen</strong>
<button class="btn secondary" onclick="closeVersionsModal()" style="padding:4px 10px;font-size:11px;">✕ Schliessen</button>
</div>
<div id="versions-body" style="overflow-y:auto;padding:4px 12px;"></div>
</div>
</div>
</body>
</html>
Binary file not shown.
+2
View File
@@ -13,6 +13,8 @@ services:
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 &&
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$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 &&
+5
View File
@@ -42,6 +42,11 @@ const ALLOWED_TYPES = new Set([
// 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",
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
"file_version_list_request", "file_version_list_response",
"file_version_download_request", "file_version_download_response",
"file_version_restore_request", "file_version_restore_response",
"service_status",
"config_request",
"flux_request", "flux_response",