Compare commits

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

Projekt jetzt komplett tsc-clean (0 Fehler).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:02:47 +02:00
10 changed files with 712 additions and 107 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20101 versionCode 20104
versionName "0.2.1.1" versionName "0.2.1.4"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.1.1", "version": "0.2.1.4",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+13 -6
View File
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
<View style={styles.container}> <View style={styles.container}>
{hasPermission ? ( {hasPermission ? (
<> <>
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
CameraScreen-Props faelschlich als required (defaultProps fuellen
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
zu umgehen, ohne echten Code zu veraendern. */}
<CameraScreen <CameraScreen
scanBarcode={true} {...({
onReadCode={handleBarcodeScan} scanBarcode: true,
showFrame={true} onReadCode: handleBarcodeScan,
frameColor="#0096FF" showFrame: true,
laserColor="#0096FF" frameColor: '#0096FF',
colorForScannerFrame="#0096FF" laserColor: '#0096FF',
} as any)}
/> />
{/* Overlay oben */} {/* Overlay oben */}
+117 -50
View File
@@ -338,6 +338,10 @@ const ChatScreen: React.FC = () => {
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert, // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
// oder AsyncStorage-Load nicht beruecksichtigt). // oder AsyncStorage-Load nicht beruecksichtigt).
const ttsCanPlayRef = useRef<boolean>(true); const ttsCanPlayRef = useRef<boolean>(true);
// Letzte Antwort: nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -990,6 +994,7 @@ const ChatScreen: React.FC = () => {
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen // file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
if (message.type === 'file_from_aria') { if (message.type === 'file_from_aria') {
const p = message.payload || {}; const p = message.payload || {};
const sp = (p.serverPath as string) || '';
const ariaMsg: ChatMessage = { const ariaMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'aria', sender: 'aria',
@@ -1000,10 +1005,20 @@ const ChatScreen: React.FC = () => {
name: (p.name as string) || 'datei', name: (p.name as string) || 'datei',
size: (p.size as number) || 0, size: (p.size as number) || 0,
mimeType: (p.mimeType as string) || '', mimeType: (p.mimeType as string) || '',
serverPath: (p.serverPath as string) || '', serverPath: sp,
}], }],
}; };
setMessages(prev => capMessages([...prev, ariaMsg])); setMessages(prev => {
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
// folge kann variieren, und die chat-Payload traegt die files jetzt
// selbst), keine doppelte Solo-Bubble anlegen.
if (sp && prev.some(m =>
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
)) {
return prev;
}
return capMessages([...prev, ariaMsg]);
});
return; return;
} }
@@ -1152,26 +1167,53 @@ const ChatScreen: React.FC = () => {
const text = (message.payload.text as string) || ''; const text = (message.payload.text as string) || '';
const ts = message.timestamp; const ts = message.timestamp;
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
// erschien, sondern erst nach einem Seitenwechsel/Reload).
const incomingFiles = Array.isArray((message.payload as any).files)
? (message.payload as any).files as Array<any> : [];
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
name: f.name || 'datei',
size: f.size || 0,
mimeType: f.mimeType || '',
serverPath: f.serverPath || '',
}));
const incomingPaths = new Set(
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
);
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren // Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
setMessages(prev => { setMessages(prev => {
const isDuplicate = prev.some(m => const isDuplicate = prev.some(m =>
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000 m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
); );
if (isDuplicate) return prev; if (isDuplicate) return prev;
const payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
const mergedAtts = [...payloadAtts, ...fileAttachments];
const ariaMsg: ChatMessage = { const ariaMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'aria', sender: 'aria',
text, text,
timestamp: ts, timestamp: ts,
attachments: message.payload.attachments as Attachment[] | undefined, attachments: mergedAtts.length ? mergedAtts : undefined,
messageId: (message.payload.messageId as string) || undefined, messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined, backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '', projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '', answeredBy: ((message.payload as any).answeredBy as string) || '',
}; };
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
const deduped = incomingPaths.size
? prev.filter(m => !(
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
))
: prev;
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered' // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet. // markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m => return capMessages([...deduped, ariaMsg]).map(m =>
m.sender === 'user' m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending') && (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' } ? { ...m, deliveryStatus: 'delivered' }
@@ -1213,6 +1255,9 @@ const ChatScreen: React.FC = () => {
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter — // ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy- // der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
// Fallback mehr: die Bridge schickt speak zuverlaessig mit. // Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false;
const _isSilent = (message.payload as any).speak === false; const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) { if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation → // Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
@@ -1451,8 +1496,10 @@ const ChatScreen: React.FC = () => {
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster, // Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen). // sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
const bg = AppState.currentState !== 'active'; const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg).catch(() => {}); wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
@@ -1528,9 +1575,15 @@ const ChatScreen: React.FC = () => {
} else { } else {
// Kein Speech im Window → Konversation beenden // Kein Speech im Window → Konversation beenden
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason); console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
// Placeholder-Bubble wieder weg // NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) { if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId)); setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
} }
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv // Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende). // lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
@@ -1942,49 +1995,6 @@ const ChatScreen: React.FC = () => {
}, timeoutMs); }, timeoutMs);
}, []); }, []);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel). // Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
const cancelRequest = useCallback(() => { const cancelRequest = useCallback(() => {
const pid = focusedProjectIdRef.current || ''; const pid = focusedProjectIdRef.current || '';
@@ -2070,6 +2080,16 @@ const ChatScreen: React.FC = () => {
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume. // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => { const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
// kein leeres Endpoint, das startPassiveStreamingRecording erneut ausloest
// (genau das liess die 30s vorher von vorne loslaufen).
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Manueller Stop im Passiv-Lauschen → exitPassiveListening (Ende)');
await wakeWordService.exitPassiveListening('manual');
return;
}
await audioService.stopStreamingRecording('user'); await audioService.stopStreamingRecording('user');
if (wakeWordService.isConversing()) { if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed'); console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
@@ -2181,6 +2201,53 @@ const ChatScreen: React.FC = () => {
setInputText(''); setInputText('');
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]); }, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// --- Rendering --- // --- Rendering ---
const renderMessage = ({ item }: { item: ChatMessage }) => { const renderMessage = ({ item }: { item: ChatMessage }) => {
+239 -30
View File
@@ -237,19 +237,38 @@ META_TOOLS = [
"speak": { "speak": {
"type": "boolean", "type": "boolean",
"description": ( "description": (
"Soll die Antwort dieses Skills VORGELESEN werden (TTS)? " "STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
"Default false. \n" "Default false.\n\n"
"- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, " "WARUM es das gibt: ARIA ist voice-first. Ein reiner "
"Rollade): es gibt nichts vorzulesen, die App beendet direkt " "STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
"und lauscht wieder aufs Wake-Word (knackig, wie ein " "vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
"Kommando).\n" "Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
"- true = ANTWORT-Skill: das Ergebnis ist eine Information, " "eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
"die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein " "'was laeuft gerade'). Also: Kommando-Skill=false, "
"Nachschlage-Wert, ein Status). Dann wird die Antwort " "Antwort-Skill=true.\n\n"
"vorgelesen und das Gespraechs-Fenster bleibt offen.\n" "PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
"Gilt fuer Fast-Path UND wenn das lokale LLM den Skill " "beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
"aufruft. Im Zweifel bei Kommandos false, bei " "vorlesen), kann dein run.py im JSON-Output pro Aufruf "
"Frage-Antwort-Skills true." "`speak` (und `converse`, s.u.) setzen — das ueberschreibt "
"diesen statischen Default. Beispiel-Output:\n"
" next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n"
" was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false,"
"\"reply\":\"Laeuft: …\"}"
),
},
"converse": {
"type": "boolean",
"description": (
"Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN "
"(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n"
"WARUM getrennt von speak: 'vorlesen' und 'weiterreden "
"koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' "
"soll vorgelesen werden (speak=true), aber es ist eine "
"abgeschlossene Einzel-Info — Stefan will danach NICHT ins "
"30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur "
"wenn eine echte Rueckfrage/ein Dialog sinnvoll ist "
"(converse=true). Am besten pro Aufruf im run.py-Output "
"setzen (dynamisch), nicht statisch."
), ),
}, },
}, },
@@ -265,6 +284,27 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
}, },
}, },
{
"type": "function",
"function": {
"name": "skill_get",
"description": (
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
"args, fast_patterns, speak/converse) + kompletter entry_code "
"(der echte Python-Code) + README. IMMER vor `skill_update` "
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
"leicht funktionierende Teile!)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name."},
},
"required": ["name"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -331,8 +371,18 @@ META_TOOLS = [
"speak": { "speak": {
"type": "boolean", "type": "boolean",
"description": ( "description": (
"Vorlesen ja/nein (siehe skill_create). false = " "Statischer Vorlese-Default (siehe skill_create). false = "
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen." "Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro "
"Aufruf via run.py-JSON-Output ueberschreibbar."
),
},
"converse": {
"type": "boolean",
"description": (
"Statischer Default: nach der Antwort 30s weiterlauschen "
"(Dialog=true) oder direkt zurueck aufs Wake-Word (false, "
"Default). Siehe skill_create. Pro Aufruf via Output "
"ueberschreibbar."
), ),
}, },
}, },
@@ -1064,6 +1114,56 @@ def _fold_umlauts(s: str) -> str:
.replace("ß", "ss")) .replace("ß", "ss"))
def _looks_like_skill_action(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Steuerbefehl-Quittung (v.a. Spotify) im Reply.
Genutzt fuer den Anti-Halluzinations-Guard: sagt local sowas, hat aber KEIN
Werkzeug gerufen, ist real nichts passiert → eskalieren. Bewusst eng, um
normale Konversation ueber Musik nicht zu treffen."""
t = (text or "").strip().lower()
if not t:
return False
if t.startswith("spotify:") or t.startswith("spotify -") or t.startswith("musik:"):
return True
if "playlist" in t and ("abspiel" in t or "spiele" in t or "starte" in t):
return True
if ("ueberspring" in t or "überspring" in t) and ("titel" in t or "lied" in t or "song" in t):
return True
return False
def _claims_live_media_state(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Live-Zustands-Auskunft, die NUR ein Werkzeug
wissen kann: aktueller Song/Interpret, Restzeit, was gerade laeuft, welches
Geraet spielt. local erfindet das gern (mal 'Midnight City von M83', mal
'The House Of House') ohne run_spotify zu rufen. Ohne echten Skill-Aufruf ist
das reine Halluzination → eskalieren (Claude ruft das Tool zuverlaessig).
Bewusst auf Medien-/Wiedergabe-Zustand begrenzt, damit normale Musik-
Konversation ('ich mag M83') NICHT getroffen wird — es muss nach einer
konkreten Ist-Zustands-Auskunft klingen."""
t = (text or "").strip().lower()
if not t:
return False
# Restzeit/Abspielposition ('noch 52 Sekunden', 'Restzeit 2:34').
if "restzeit" in t or "restlaufzeit" in t:
return True
if re.search(r"\bnoch\s+\d+\s*(sekunde|minute|sek|min)", t):
return True
# 'aktueller Song/Titel/Lied heisst/ist …' / 'es laeuft gerade …' /
# 'spielt gerade …' / 'jetzt laeuft …' — Ist-Zustands-Auskunft.
if re.search(r"(aktuell\w*\s+(song|titel|lied|stueck|track))", t):
return True
if re.search(r"(l(ae|ä)uft|spielt)\s+(gerade|jetzt|aktuell|zurzeit|grade)", t):
return True
if re.search(r"(gerade|jetzt|zurzeit|grade)\s+(l(ae|ä)uft|spielt)", t):
return True
# Skip-Quittung, die einen konkreten Folgetitel behauptet ('… ist jetzt „X"').
if ("ueberspring" in t or "übersprung" in t or "uebersprung" in t) and (
"song" in t or "titel" in t or "lied" in t):
return True
return False
def _normalize_for_fast_match(text: str) -> str: def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower() norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm) norm = _fold_umlauts(norm)
@@ -1179,8 +1279,12 @@ class Agent:
continue continue
args = pat.get("args") or {} args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok" reply = pat.get("reply") or f"{skill_name}: ok"
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl). # Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
# Skill darf ein Dialog-Skill sein.
self._fast_path_speak = bool(skill.get("speak", False)) self._fast_path_speak = bool(skill.get("speak", False))
self._fast_path_converse = bool(skill.get("converse", False))
logger.info("[fast-path] match skill=%s pattern=%r msg=%r", logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60]) skill_name, rx, user_message[:60])
try: try:
@@ -1193,6 +1297,16 @@ class Agent:
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines() tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120] hint = (tail[-1] if tail else "")[:120]
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}" return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
try:
_j = json.loads((res.get("stdout") or "").strip())
if isinstance(_j, dict):
if isinstance(_j.get("speak"), bool):
self._fast_path_speak = _j["speak"]
if isinstance(_j.get("converse"), bool):
self._fast_path_converse = _j["converse"]
except Exception:
pass
return reply return reply
return None return None
@@ -1248,11 +1362,10 @@ class Agent:
break break
return tools return tools
def _skill_speak_flag(self, tname: str) -> bool: def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False = """run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche)."""
if not tname.startswith("run_"): if not tname.startswith("run_"):
return True return None
suffix = tname[len("run_"):] suffix = tname[len("run_"):]
m = skills_mod.read_manifest(suffix) m = skills_mod.read_manifest(suffix)
if m is None: if m is None:
@@ -1261,7 +1374,28 @@ class Agent:
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix: if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
m = cand m = cand
break break
return bool((m or {}).get("speak", False)) return m
def _skill_speak_flag(self, tname: str) -> bool:
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False)."""
if not tname.startswith("run_"):
return True
return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False))
def _skill_response_flags(self, tname: str) -> tuple:
"""(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill.
Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) >
Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich
sein — 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop."""
flags = getattr(self, "_last_skill_flags", None) or {}
m = self._resolve_skill_manifest(tname) or {}
speak = bool(m.get("speak", False))
converse = bool(m.get("converse", False))
if isinstance(flags.get("speak"), bool):
speak = flags["speak"]
if isinstance(flags.get("converse"), bool):
converse = flags["converse"]
return speak, converse
def _try_local_fast_lane(self, user_message: str, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
@@ -1273,9 +1407,28 @@ class Agent:
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True). # dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True. # Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
self._local_turn_speak = True self._local_turn_speak = True
# Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via
# web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default
# False = Einzelaktion).
self._local_turn_converse = True
# Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
# Bestaetigung durchzulassen.
self._local_ran_skill = False
local_only = bool(cfg.get("localOnly")) local_only = bool(cfg.get("localOnly"))
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools # B1a-Rueckbau (bewusst): local ist TOOL-LOS — nur reines Reden. Ein 8B
# ist ein unzuverlaessiger Tool-Caller: mit Werkzeug in der Hand erfindet
# er lieber eine plausible Antwort ('der Song ist X'), statt das Tool zu
# rufen — das war die Halo-Quelle UND zwang zu per-Skill-Guards. Ohne
# Tools KANN er kein Skill-Ergebnis faelschen. Alles mit Grundwahrheit
# (Fakt/Live-Zustand/Gedaechtnis/Aktion) gehoert an Claude oder an den
# deterministischen Fast-Path — nicht an den 8B. WIE es dorthin kommt:
# das Modell eskaliert SELBST (<<ESCALATE>>, siehe Prompt) — bewusst KEINE
# Input-Wortliste im Router. Der Output-Guard unten faengt ab, wenn der
# 8B doch mal statt zu eskalieren einen Live-Zustand behauptet.
tools = []
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR, sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools)) has_tools=bool(tools))
@@ -1324,7 +1477,9 @@ class Agent:
# Turn das speak-Flag des Skills (Steuerbefehl=stumm, # Turn das speak-Flag des Skills (Steuerbefehl=stumm,
# Antwort-Skill=vorlesen). Letzter Skill gewinnt. # Antwort-Skill=vorlesen). Letzter Skill gewinnt.
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult): if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
self._local_turn_speak = self._skill_speak_flag(tname) self._local_turn_speak, self._local_turn_converse = \
self._skill_response_flags(tname)
self._local_ran_skill = True
if self._local_tool_failed(tname, tresult): if self._local_tool_failed(tname, tresult):
had_error = True had_error = True
messages.append({"role": "tool", messages.append({"role": "tool",
@@ -1370,6 +1525,15 @@ class Agent:
logger.info("[router] lokal eskaliert → Claude") logger.info("[router] lokal eskaliert → Claude")
return None return None
# Anti-Halluzination: local behauptet manchmal eine Musik-/Skill-Aktion
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
if not self._local_ran_skill and (
_looks_like_skill_action(final) or _claims_live_media_state(final)):
logger.info("[router] lokal: Aktion/Live-Zustand behauptet ohne Tool-Call → Claude")
return None
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final)) logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
self.conversation.add("assistant", final, project_id=active_project_id) self.conversation.add("assistant", final, project_id=active_project_id)
return final return final
@@ -1422,7 +1586,9 @@ class Agent:
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm, # Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl). # Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
speak = bool(getattr(self, "_fast_path_speak", False)) speak = bool(getattr(self, "_fast_path_speak", False))
return fast_reply, "fast-path", speak # converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
return fast_reply, "fast-path", speak, converse
# 1. User-Turn an die Konversation # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1436,10 +1602,11 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id) local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None: if local_reply is not None:
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm + # speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech. # dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True) speak = getattr(self, "_local_turn_speak", True)
return local_reply, "local", speak converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# 2. Hot Memory (alle pinned Punkte) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1561,6 +1728,7 @@ class Agent:
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine # der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
# Konversation bleiben gesprochen. # Konversation bleiben gesprochen.
self._claude_turn_speak = True self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -1582,7 +1750,8 @@ class Agent:
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt). # ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or "" _tn = tc.get("name") or ""
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result): if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
self._claude_turn_speak = self._skill_speak_flag(_tn) self._claude_turn_speak, self._claude_turn_converse = \
self._skill_response_flags(_tn)
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer # Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war # Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists # viel zu wenig: Spotify _all=true mit 90 Playlists
@@ -1651,8 +1820,10 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True. # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True)) return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -1674,6 +1845,7 @@ class Agent:
config_schema=arguments.get("config_schema") or None, config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None, fast_patterns=arguments.get("fast_patterns") or None,
speak=bool(arguments.get("speak", False)), speak=bool(arguments.get("speak", False)),
converse=bool(arguments.get("converse", False)),
author="aria", author="aria",
) )
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1725,6 +1897,29 @@ class Agent:
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}" f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
for s in items for s in items
) )
if name == "skill_get":
sk_name = (arguments.get("name") or "").strip()
if not sk_name:
return "FEHLER: name ist Pflicht."
src = skills_mod.read_skill_source(sk_name)
if src is None:
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
m = src["manifest"]
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
meta = {
"name": m.get("name"), "description": m.get("description"),
"execution": m.get("execution"), "entry": m.get("entry"),
"active": m.get("active"), "args": m.get("args"),
"requires": m.get("requires"),
"config_schema": m.get("config_schema"),
"fast_patterns": m.get("fast_patterns"),
"speak": m.get("speak"), "converse": m.get("converse"),
}
return (
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
f"README:\n{src.get('readme') or '(leer)'}"
)
if name == "skill_update": if name == "skill_update":
skill_name = (arguments.get("name") or "").strip() skill_name = (arguments.get("name") or "").strip()
if not skill_name: if not skill_name:
@@ -1735,6 +1930,8 @@ class Agent:
patch[k] = arguments[k] patch[k] = arguments[k]
if "speak" in arguments and arguments["speak"] is not None: if "speak" in arguments and arguments["speak"] is not None:
patch["speak"] = bool(arguments["speak"]) patch["speak"] = bool(arguments["speak"])
if "converse" in arguments and arguments["converse"] is not None:
patch["converse"] = bool(arguments["converse"])
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"] patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list): if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
@@ -1836,6 +2033,18 @@ class Agent:
skill_name = cand_name skill_name = cand_name
break break
res = skills_mod.run_skill(skill_name, args=arguments) res = skills_mod.run_skill(skill_name, args=arguments)
# Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer
# DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path)
# liest sie und steuert damit Vorlesen (speak) + 30s-Weiter-
# lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht
# gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False.
self._last_skill_flags = None
if res.get("ok"):
try:
_j = json.loads((res.get("stdout") or "").strip())
self._last_skill_flags = _j if isinstance(_j, dict) else None
except Exception:
self._last_skill_flags = None
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB, # 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA # da wurde der Track-Name regelmaessig abgeschnitten und ARIA
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte # hat aus dem Album-Kontext halluziniert. Claude kann hunderte
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply, _, _ = agent.chat(prompt, source="trigger") reply, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+5 -1
View File
@@ -636,6 +636,9 @@ class ChatOut(BaseModel):
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False; # Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag. # ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True speak: bool = True
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = True
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
reply, answered_by, speak = await loop.run_in_executor( reply, answered_by, speak, converse = await loop.run_in_executor(
None, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
project_id=pid, project_id=pid,
answered_by=answered_by, answered_by=answered_by,
speak=speak, speak=speak,
converse=converse,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+52 -4
View File
@@ -75,6 +75,16 @@ _TOOL_HINTS = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten). # Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
_HARD_HINTS = re.compile( _HARD_HINTS = re.compile(
r"```|" # Codeblock r"```|" # Codeblock
@@ -86,6 +96,18 @@ _HARD_HINTS = re.compile(
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude _MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
_FORCE_CLAUDE = re.compile(
r"\b(clodi|clody)\b"
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
re.IGNORECASE,
)
def _strip_leading_hint_blocks(text: str) -> str: def _strip_leading_hint_blocks(text: str) -> str:
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg — """Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
@@ -108,6 +130,10 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
msg = _strip_leading_hint_blocks(user_message) msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL: if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False return False
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
if _FORCE_CLAUDE.search(msg):
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
return False
if _TOOL_HINTS.search(msg): if _TOOL_HINTS.search(msg):
return False return False
if _HARD_HINTS.search(msg): if _HARD_HINTS.search(msg):
@@ -122,10 +148,16 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
# kein volles Memory (B1a). # kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude. # Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
# Aktion braucht, gehoert ans grosse Modell.
_AWARENESS = ( _AWARENESS = (
"Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, " "Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
"Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, " "nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
"sowie tiefe/technische Analysen und langen Code." "News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
"und langen Code. Fuer ALL das eskalierst du."
) )
@@ -153,10 +185,26 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
"GPS-Hinweis in der Nachricht.", "GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).", "- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').", "- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).", "- `run_*`-Skills: konkrete Faehigkeiten (z.B. Musik/Spotify u.a.). "
"Was ein Skill kann + welche Parameter er nimmt, steht in SEINER "
"Tool-Beschreibung — LIES sie und nutze den Skill fuer ALLES was "
"dazu passt (nicht nur die offensichtlichen Faelle). Nie selbst eine "
"Aktion 'spielen'/'nachschauen', wenn ein Skill das kann.",
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) " "WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem " "KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"Bedarf; erfinde keine.", "Bedarf; erfinde keine.",
"ANTI-HALLUZINATION (kritisch): Behaupte NIEMALS eine Aktion als "
"erledigt, ohne das Werkzeug WIRKLICH aufgerufen zu haben. Keine "
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
] ]
parts += [ parts += [
"", "",
+30 -3
View File
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
return None return None
def read_skill_source(name: str) -> Optional[dict]:
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
der bestehende Funktionen killt)."""
m = read_manifest(name)
if m is None:
return None
d = _skill_dir(name)
entry = m.get("entry", "run.sh")
try:
code = (d / entry).read_text(encoding="utf-8")
except Exception as exc:
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
try:
readme = (d / "README.md").read_text(encoding="utf-8")
except Exception:
readme = ""
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
def write_manifest(name: str, manifest: dict) -> None: def write_manifest(name: str, manifest: dict) -> None:
d = _skill_dir(name) d = _skill_dir(name)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -166,6 +186,7 @@ def create_skill(
config_schema: Optional[list] = None, config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None, fast_patterns: Optional[list] = None,
speak: bool = False, speak: bool = False,
converse: bool = False,
) -> dict: ) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -218,9 +239,12 @@ def create_skill(
"fast_patterns": _normalize_fast_patterns(fast_patterns), "fast_patterns": _normalize_fast_patterns(fast_patterns),
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)? # speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App # False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen + # beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
# Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung. # converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False
# (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im
# JSON-Output pro Aufruf ueberschreiben (gemischte Skills).
"speak": bool(speak), "speak": bool(speak),
"converse": bool(converse),
"version_history": [], "version_history": [],
} }
write_manifest(name, manifest) write_manifest(name, manifest)
@@ -341,12 +365,15 @@ def update_skill(name: str, patch: dict) -> dict:
# nach archive_current_version manifest neu laden (version_history geupdatet) # nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest manifest = read_manifest(name) or manifest
allowed = {"description", "args", "requires", "active", "version", "entry", "speak"} allowed = {"description", "args", "requires", "active", "version", "entry",
"speak", "converse"}
for k, v in patch.items(): for k, v in patch.items():
if k in allowed: if k in allowed:
manifest[k] = v manifest[k] = v
if "speak" in patch: if "speak" in patch:
manifest["speak"] = bool(patch["speak"]) manifest["speak"] = bool(patch["speak"])
if "converse" in patch:
manifest["converse"] = bool(patch["converse"])
if "config_schema" in patch: if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
if "fast_patterns" in patch: if "fast_patterns" in patch:
+252 -9
View File
@@ -16,6 +16,7 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import math
import mimetypes import mimetypes
import os import os
import re import re
@@ -32,6 +33,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import subprocess import subprocess
import urllib.parse
import urllib.request import urllib.request
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@@ -293,10 +295,17 @@ def clean_text_for_tts(text: str) -> str:
if not text: if not text:
return "" return ""
# <voice>...</voice> wenn vorhanden → nur das nehmen # <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE) voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
if voice_match: if voice_match and voice_match.group(1).strip():
text = voice_match.group(1) text = voice_match.group(1)
else:
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
t = text t = text
@@ -370,6 +379,37 @@ def clean_text_for_tts(text: str) -> str:
return t.strip() return t.strip()
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
# gerundeter Bearing / 45 (mod 8).
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz in Metern zwischen zwei GPS-Punkten."""
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
def _heading_word(bearing: float) -> str:
return _COMPASS_ADV[round(bearing / 45.0) % 8]
# ── STT Engine ─────────────────────────────────────────────── # ── STT Engine ───────────────────────────────────────────────
@@ -1175,8 +1215,28 @@ class ARIABridge:
"lat": float(lat), "lat": float(lat),
"lon": float(lon), "lon": float(lon),
}) })
lat, lon = float(lat), float(lon)
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
self._last_location = {"lat": lat, "lon": lon}
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
MIN_MOVE_M = 25.0
anchor = getattr(self, "_heading_anchor", None)
if anchor:
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
if moved >= MIN_MOVE_M:
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
self._last_heading = _heading_word(_bg)
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
self._heading_anchor = {"lat": lat, "lon": lon}
else:
self._heading_anchor = {"lat": lat, "lon": lon}
except Exception: except Exception:
return return
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
self._maybe_reverse_geocode(float(lat), float(lon))
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet # Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
# oder langsam ist, blockt das nicht den GPS-Pfad. # oder langsam ist, blockt das nicht den GPS-Pfad.
try: try:
@@ -1184,6 +1244,150 @@ class ARIABridge:
except Exception: except Exception:
pass pass
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
(~1 km Raster) und im Hintergrund. Ergebnis landet in
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
try:
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
except Exception:
return
if key == getattr(self, "_geocoded_key", None):
return # diese Gegend schon aufgeloest
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
# Stadt bis der neue Geocode da ist.
self._geocoded_key = key
self._last_place_name = ""
try:
self._geocode_task = asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
except Exception:
self._geocode_task = None
async def _ensure_place_name(self, lat, lon) -> None:
"""Stellt (blockierend, mit Timeout) sicher, dass der Ortsname fuer die
aktuelle Position da ist — fuer die ERSTE Nachricht an einem neuen Ort,
wo der Hintergrund-Geocode noch laeuft. Gecacht → danach instant."""
try:
lat, lon = float(lat), float(lon)
key = (round(lat, 3), round(lon, 3))
except Exception:
return
if key == getattr(self, "_geocoded_key", None) and getattr(self, "_last_place_name", ""):
return # schon aufgeloest
if key != getattr(self, "_geocoded_key", None):
self._maybe_reverse_geocode(lat, lon) # startet Task
task = getattr(self, "_geocode_task", None)
if task is not None and not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=6)
except Exception:
pass # Timeout/Fehler → Praefix faellt auf reine Koordinaten zurueck
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
(Overpass-Milestone). Setzt self._last_place_name. Fehler → still
(Praefix faellt auf reine Koordinaten zurueck)."""
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
def _fetch():
try:
req = urllib.request.Request(url, headers={
# Nominatim verlangt einen aussagekraeftigen User-Agent.
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
return None
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(data, dict):
return
addr = data.get("address") or {}
extra = data.get("extratags") or {}
road = addr.get("road") or ""
house = addr.get("house_number") or ""
city = (addr.get("city") or addr.get("town") or addr.get("village")
or addr.get("municipality") or addr.get("county") or "")
plz = addr.get("postcode") or ""
state = addr.get("state") or ""
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
# Strasse + Hausnummer
street = (f"{road} {house}".strip()) if road else ""
# PLZ + Ort
citypart = (f"{plz} {city}".strip()) if plz else city
segs: list[str] = []
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
is_road = bool(ref) or any(w in road.lower()
for w in ("autobahn", "bundesstrasse", "bundesstraße",
"kreisstrasse", "kreisstraße"))
if is_road:
label = ref or road
km = await self._overpass_km(lat, lon)
if km:
label = f"{label} bei km {km}"
if label:
segs.append(label)
if street:
segs.append(street)
if citypart:
segs.append(citypart)
if state and (state not in citypart):
segs.append(state)
name = ", ".join(s for s in segs if s)
if not name:
name = data.get("name") or ""
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
if name and getattr(self, "_geocoded_key", None) == key:
self._last_place_name = name
logger.info("[geo] %.5f,%.5f%s", lat, lon, name)
async def _overpass_km(self, lat: float, lon: float) -> str:
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
query = (f"[out:json][timeout:8];"
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
def _fetch():
try:
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
req = urllib.request.Request(ov, data=body, headers={
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=12) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
return None
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(d, dict):
return ""
best_dist = ""
best_sq = 1e18
for e in (d.get("elements") or []):
tags = e.get("tags") or {}
dist = tags.get("distance") or tags.get("milestone:distance") or ""
elat, elon = e.get("lat"), e.get("lon")
if not dist or elat is None or elon is None:
continue
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
if sq < best_sq:
best_sq = sq
best_dist = str(dist)
return best_dist.replace(".", ",") if best_dist else ""
async def _trigger_brain_check_now(self) -> None: async def _trigger_brain_check_now(self) -> None:
"""Brain-Endpoint POST /triggers/check-now anstossen.""" """Brain-Endpoint POST /triggers/check-now anstossen."""
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
@@ -1356,6 +1560,13 @@ class ARIABridge:
"text": display_text, "text": display_text,
"sender": "aria", "sender": "aria",
"messageId": message_id, "messageId": message_id,
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
# der Anhang live nur als separates file_from_aria-Event (eigene
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
# Struktur wie im Backup, damit App live == Reload rendert.
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als # backupTs = der ts in chat_backup.jsonl. Wird von Clients als
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request). # Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
"backupTs": assistant_backup_ts, "backupTs": assistant_backup_ts,
@@ -1371,6 +1582,9 @@ class ARIABridge:
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr # und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
# nach einem Fast-Path-Befehl grau haengen. # nach einem Fast-Path-Befehl grau haengen.
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True, "speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True,
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1557,14 +1771,33 @@ class ARIABridge:
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, " "gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]" "Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
) )
if location and isinstance(location, dict): # Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
lat = location.get("lat") # (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
lon = location.get("lon") or location.get("lng") loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
else getattr(self, "_last_location", None)
if isinstance(loc, dict):
lat = loc.get("lat")
lon = loc.get("lon") or loc.get("lng")
if lat is not None and lon is not None: if lat is not None and lon is not None:
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
place = getattr(self, "_last_place_name", "")
where = f"{place} " if place else ""
heading = getattr(self, "_last_heading", "")
bearing = getattr(self, "_last_bearing", None)
if heading and bearing is not None:
moving = f", unterwegs {heading} ({bearing} Grad)"
elif heading:
moving = f", unterwegs {heading}"
else:
moving = ""
parts.append( parts.append(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. " f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. " f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]" f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
f"nicht von dir aus, ausser er fragt explizit danach.]"
) )
if parts: if parts:
return " ".join(parts) + " " + text return " ".join(parts) + " " + text
@@ -1712,6 +1945,9 @@ class ARIABridge:
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag # Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text. # vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
speak = data.get("speak", True) speak = data.get("speak", True)
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
converse = data.get("converse", True)
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -1780,7 +2016,8 @@ class ARIABridge:
try: try:
await self._process_core_response(reply, {"projectId": turn_project_id, await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak}) "speak": speak,
"converse": converse})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)
@@ -2087,6 +2324,9 @@ class ARIABridge:
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'", logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
str(payload.get("projectId") or "") or "(main)", text[:80]) str(payload.get("projectId") or "") or "(main)", text[:80])
else: else:
_lg = location or getattr(self, "_last_location", None)
if isinstance(_lg, dict) and _lg.get("lat") is not None:
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
core_text = self._build_core_text(text, interrupted, location) core_text = self._build_core_text(text, interrupted, location)
logger.info("[rvs] App-Chat%s%s: '%s'", logger.info("[rvs] App-Chat%s%s: '%s'",
" [BARGE-IN]" if interrupted else "", " [BARGE-IN]" if interrupted else "",
@@ -3184,6 +3424,9 @@ class ARIABridge:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e) logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
# Dann an Brain — der blockt synchron bis ARIA fertig ist. # Dann an Brain — der blockt synchron bis ARIA fertig ist.
_lg = location or getattr(self, "_last_location", None)
if isinstance(_lg, dict) and _lg.get("lat") is not None:
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
core_text = self._build_core_text(text, interrupted, location) core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, await self.send_to_core(core_text,
source="app-voice" + (" [barge-in]" if interrupted else ""), source="app-voice" + (" [barge-in]" if interrupted else ""),