diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index c9c4c90..5ba3462 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -1410,11 +1410,21 @@ const ChatScreen: React.FC = () => { if (_isSilent) { // Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen. // Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations- - // Lifecycle haengt sonst (das Ohr bleibt grau, das Aufnahme-Fenster offen). - // ARIA "drueckt hier selbst Stop": jede offene Aufnahme schliessen + - // zurueck aufs Wake-Word, egal in welchem Zustand (conversing, passives - // Lauschen ODER eine noch offene Streaming-Aufnahme). - ariaStopRecording('silent-command').catch(() => {}); + // Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr. + if (converseRef.current) { + // Befehlskette laeuft WEITER ([[WEITER]]): Mikro NICHT schliessen, + // sondern das passive Lausch-Fenster oeffnen (endConversation(false)), + // damit der naechste Kettenbefehl direkt gesprochen werden kann. ARIA + // haelt bewusst offen, bis sie [[ENDE]] (converse=false) schickt. + if (wakeWordService.isConversing()) { + wakeWordService.endConversation(false).catch(() => {}); + } + } else { + // Einzelbefehl / [[ENDE]] → ARIA "drueckt selbst Stop": jede offene + // Aufnahme schliessen + zurueck aufs Wake-Word, egal in welchem Zustand + // (conversing, passives Lauschen ODER offene Streaming-Aufnahme). + ariaStopRecording('silent-command').catch(() => {}); + } } } diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 98e0e8a..2193fe0 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1347,6 +1347,53 @@ def _extract_await_marker(text: str) -> tuple: return text, False +# ── Sprach-/Gespraechs-Steuermarker (ARIA deklariert die Phase SELBST) ── +# +# Voice-First: ARIA erkennt aus dem Text, ob Stefan einen BEFEHL gibt (etwas tun) +# oder eine FRAGE stellt (etwas wissen), und ob das Gespraech/eine Befehlskette +# weiterlaeuft oder endet. Sie haengt dazu Marker ans Ende ihrer Antwort — genau +# wie [[AWAIT]], und sie werden ebenso entfernt (nicht angezeigt/vorgelesen/in +# History). Der Marker ist AUTORITATIV — er ueberschreibt das Skill-Manifest-Flag, +# denn dasselbe Skill (z.B. VM-/GUI-Steuerung) ist mal Befehl, mal Auskunft; nur +# ARIA weiss aus dem Kontext, was gerade gemeint ist. +# +# [[STUMM]] -> reiner Steuerbefehl: NICHT vorlesen (speak=false). Allein = +# Einzelbefehl → danach zurueck aufs Wake-Word (converse=false). +# [[WEITER]] -> Konversation/Befehlskette laeuft weiter: Mikro offen halten +# (converse=true) — kein erneutes "Computer" noetig. +# [[ENDE]] -> Konversation/Kette beenden: zurueck aufs Wake-Word (converse=false). +_SILENT_MARKER_RE = re.compile(r"\[\[\s*STUMM\s*\]\]", re.IGNORECASE) +_CONT_MARKER_RE = re.compile(r"\[\[\s*WEITER\s*\]\]", re.IGNORECASE) +_END_MARKER_RE = re.compile(r"\[\[\s*ENDE\s*\]\]", re.IGNORECASE) + + +def _extract_flow_markers(text: str) -> tuple: + """Zieht [[STUMM]]/[[WEITER]]/[[ENDE]] aus dem finalen Text. + Gibt (clean_text, speak_override, converse_override) zurueck; ein Override ist + None, wenn der jeweilige Marker fehlt (dann gilt Default/Skill-Flag). + Regeln: [[STUMM]] alleine = Einzelbefehl → auch converse=false (Mikro zu), + ausser [[WEITER]] haelt es explizit offen. [[ENDE]] gewinnt gegen [[WEITER]].""" + if not text: + return text, None, None + speak_ov = None + conv_ov = None + if _SILENT_MARKER_RE.search(text): + speak_ov = False + text = _SILENT_MARKER_RE.sub("", text) + if _END_MARKER_RE.search(text): + conv_ov = False + text = _END_MARKER_RE.sub("", text) + if _CONT_MARKER_RE.search(text): + # [[ENDE]] hat Vorrang — widerspruechliche Marker → beenden. + if conv_ov is None: + conv_ov = True + text = _CONT_MARKER_RE.sub("", text) + # Stiller Einzelbefehl ohne explizites Weiterlauschen → Mikro zu. + if speak_ov is False and conv_ov is None: + conv_ov = False + return text.strip(), speak_ov, conv_ov + + def _normalize_for_fast_match(text: str) -> str: norm = _strip_leading_hint_blocks(text).lower() norm = _fold_umlauts(norm) @@ -2033,16 +2080,24 @@ class Agent: # Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit # er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet). final_reply, awaiting_reply = _extract_await_marker(final_reply) + # ARIAs Phasen-Marker ([[STUMM]]/[[WEITER]]/[[ENDE]]) ziehen — VOR History, + # damit sie nicht angezeigt/vorgelesen/gespeichert werden. + final_reply, _speak_ov, _conv_ov = _extract_flow_markers(final_reply) # 7. Assistant-Turn (final reply) in die Conversation self.conversation.add("assistant", final_reply, project_id=active_project_id) - # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech); + # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech). + # ARIAs Phasen-Marker sind AUTORITATIV: sie kennt aus dem Text den Unter- + # schied Befehl/Frage und Kette/Ende, den das Skill-Manifest nicht kennt. + speak = bool(getattr(self, "_claude_turn_speak", True)) + converse = bool(getattr(self, "_claude_turn_converse", True)) + if _speak_ov is not None: + speak = _speak_ov + if _conv_ov is not None: + converse = _conv_ov # awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert). - return (final_reply, "claude", - bool(getattr(self, "_claude_turn_speak", True)), - bool(getattr(self, "_claude_turn_converse", True)), - awaiting_reply) + return (final_reply, "claude", speak, converse, awaiting_reply) # ── Tool-Dispatcher ─────────────────────────────────────── diff --git a/aria-brain/prompts.py b/aria-brain/prompts.py index 8faf870..f54e1dc 100644 --- a/aria-brain/prompts.py +++ b/aria-brain/prompts.py @@ -162,6 +162,46 @@ def build_time_section() -> str: ] return "\n".join(lines) +def build_voice_flow_section() -> str: + """Sprach-/Gespraechssteuerung: ARIA erkennt AUS DEM TEXT die Phase (Befehl vs. + Frage, Kette vs. Ende) und deklariert sie per Marker — wie [[AWAIT]]. Die + Marker werden im Brain entfernt (nie angezeigt/vorgelesen).""" + return "\n".join([ + "## Sprach- & Gespraechssteuerung (Voice-First — du entscheidest die Phase)", + "Stefan spricht meist mit dir. DU erkennst aus dem Text, was gerade Phase " + "ist — niemand raet das fuer dich. Dazu haengst du EINEN Marker (bei Bedarf " + "zwei) ganz ans ENDE deiner Antwort. Sie werden entfernt: nicht angezeigt, " + "nicht vorgelesen, nicht gespeichert — genau wie `[[AWAIT]]`.", + "", + "- `[[STUMM]]` → Deine Antwort ist ein reiner **Steuerbefehl** (du hast etwas " + "GETAN: Musik, VNC oeffnen, einen Menuepunkt klicken, Licht …). Sie wird " + "NICHT vorgelesen; der kurze Bestaetigungstext steht nur in der Bubble. " + "Setz das IMMER, wenn Stefan dir einen Befehl gibt statt eine Frage stellt — " + "AUCH wenn du den Befehl ueber ein Skill/Tool ausfuehrst (nicht nur beim " + "Fast-Path). `[[STUMM]]` ALLEIN = Einzelbefehl → danach direkt zurueck aufs " + "Wake-Word.", + "- `[[WEITER]]` → Das Gespraech bzw. eine **Befehlskette** laeuft weiter: das " + "Mikro bleibt offen, du wartest auf die naechste Eingabe (kein erneutes " + "\"Computer\" noetig). Setz das, wenn Stefan eine Kette ankuendigt ('ich geb " + "dir gleich mehrere Befehle', 'wir machen das jetzt Schritt fuer Schritt') " + "oder das Gespraech klar weitergeht.", + "- `[[ENDE]]` → Konversation/Kette ist zu Ende: zurueck aufs Wake-Word. Setz " + "das, wenn Stefan schliesst ('das war's', 'Konversation Ende', 'Befehlskette " + "Ende', 'danke, fertig'). Stellt er in DERSELBEN Nachricht noch eine Frage, " + "beantworte sie normal (OHNE `[[STUMM]]`, wird also vorgelesen) UND haeng " + "`[[ENDE]]` an.", + "", + "Regeln:", + "- Befehl (etwas TUN) → `[[STUMM]]`. Frage (etwas WISSEN / plaudern) → normal, " + "ohne Marker (wird vorgelesen).", + "- Befehlskette: JEDER Schritt `[[STUMM]] [[WEITER]]` (stumm arbeiten, Mikro " + "offen), bis Stefan die Kette beendet → letzter Turn `[[ENDE]]`.", + "- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche " + "danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').", + "- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.", + ]) + + TYPE_HEADINGS = { "identity": "## Wer du bist", "rule": "## Sicherheitsregeln & Prinzipien", @@ -463,7 +503,8 @@ def build_system_prompt( """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth.""" # Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die # ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt. - parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section()] + parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(), + "", build_voice_flow_section()] if skills: parts.append("") parts.append(build_skills_section(skills))