diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index bd354ea..2b5778f 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -1209,8 +1209,11 @@ const ChatScreen: React.FC = () => { // grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie // bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume), // sonst → Konversation beenden (re-arm). - const _isSilent = (message.payload as any).speak === false - || ((message.payload as any).answeredBy === 'fast-path'); + // Stumm = die Bridge sagt speak=false (Steuerbefehl-Skill via Fast-Path + // ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter — + // der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy- + // Fallback mehr: die Bridge schickt speak zuverlaessig mit. + const _isSilent = (message.payload as any).speak === false; if (_isSilent && wakeWordService.isConversing()) { // Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation → // STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme, diff --git a/aria-brain/agent.py b/aria-brain/agent.py index e0e2fce..7ec2140 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -234,6 +234,24 @@ META_TOOLS = [ "\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]" ), }, + "speak": { + "type": "boolean", + "description": ( + "Soll die Antwort dieses Skills VORGELESEN werden (TTS)? " + "Default false. \n" + "- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, " + "Rollade): es gibt nichts vorzulesen, die App beendet direkt " + "und lauscht wieder aufs Wake-Word (knackig, wie ein " + "Kommando).\n" + "- true = ANTWORT-Skill: das Ergebnis ist eine Information, " + "die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein " + "Nachschlage-Wert, ein Status). Dann wird die Antwort " + "vorgelesen und das Gespraechs-Fenster bleibt offen.\n" + "Gilt fuer Fast-Path UND wenn das lokale LLM den Skill " + "aufruft. Im Zweifel bei Kommandos false, bei " + "Frage-Antwort-Skills true." + ), + }, }, "required": ["name", "description", "entry_code"], }, @@ -310,6 +328,13 @@ META_TOOLS = [ "wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt." ), }, + "speak": { + "type": "boolean", + "description": ( + "Vorlesen ja/nein (siehe skill_create). false = " + "Steuerbefehl/stumm, true = Antwort-Skill/vorlesen." + ), + }, }, "required": ["name"], }, @@ -1154,6 +1179,8 @@ class Agent: continue args = pat.get("args") or {} reply = pat.get("reply") or f"{skill_name}: ok" + # Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl). + self._fast_path_speak = bool(skill.get("speak", False)) logger.info("[fast-path] match skill=%s pattern=%r msg=%r", skill_name, rx, user_message[:60]) try: @@ -1221,16 +1248,31 @@ class Agent: break return tools + def _skill_speak_flag(self, tname: str) -> bool: + """speak-Flag eines run_*-Skills aus dem Manifest (Default False = + Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche).""" + if not tname.startswith("run_"): + return True + suffix = tname[len("run_"):] + m = skills_mod.read_manifest(suffix) + if m is None: + for cand in skills_mod.list_skills(active_only=False): + cn = cand.get("name") or "" + if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix: + m = cand + break + return bool((m or {}).get("speak", False)) + def _try_local_fast_lane(self, user_message: str, active_project_id: str) -> Optional[str]: cfg = router_mod.load_config() if not router_mod.should_try_local(user_message, cfg): return None - # Merker: hat dieser lokale Turn einen echten Skill (run_*) ausgefuehrt? - # Dann ist es ein Steuerbefehl (z.B. Spotify) → NICHT vorlesen (speak= - # False), wie beim Fast-Path. Info-Tools (web_search/memory_search/ - # trigger_timer) zaehlen NICHT — deren Antwort/Bestaetigung wird gesprochen. - self._local_turn_executed_skill = False + # Vorlesen ja/nein fuer diesen lokalen Turn. Default True (Info-Antwort). + # Fuehrt local einen Skill (run_*) aus, uebernimmt dessen speak-Flag aus + # dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True). + # Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True. + self._local_turn_speak = True local_only = bool(cfg.get("localOnly")) local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key tools = self._build_local_tools() # B1b: kuratierte Tools @@ -1278,10 +1320,11 @@ class Agent: logger.info("[router] lokal Tool-Call: %s(%s)", tname, ", ".join(targs.keys())) tresult = self._dispatch_tool(tname, targs) - # Echter Skill (run_*) ausgefuehrt = Steuerbefehl → nachher - # nicht vorlesen. Nur bei Erfolg (Fehler → eskaliert eh). + # Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser + # Turn das speak-Flag des Skills (Steuerbefehl=stumm, + # Antwort-Skill=vorlesen). Letzter Skill gewinnt. if tname.startswith("run_") and not self._local_tool_failed(tname, tresult): - self._local_turn_executed_skill = True + self._local_turn_speak = self._skill_speak_flag(tname) if self._local_tool_failed(tname, tresult): had_error = True messages.append({"role": "tool", @@ -1376,9 +1419,10 @@ class Agent: metrics.log_fast_path(fast_reply) except Exception: pass - # Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False). - # System-Flag statt -Tag: robust, unabhaengig vom Skill-Inhalt. - return fast_reply, "fast-path", False + # Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm, + # Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl). + speak = bool(getattr(self, "_fast_path_speak", False)) + return fast_reply, "fast-path", speak # 1. User-Turn an die Konversation self.conversation.add("user", user_message, source=source, @@ -1392,10 +1436,9 @@ class Agent: # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. local_reply = self._try_local_fast_lane(user_message, active_project_id) if local_reply is not None: - # Hat local einen echten Skill (run_*) ausgefuehrt → Steuerbefehl, - # NICHT vorlesen (kein TTS, App beendet direkt statt 30s-Gespraech) — - # genau wie Fast-Path. Sonst normale gesprochene Antwort. - speak = not getattr(self, "_local_turn_executed_skill", False) + # speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm + + # App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech. + speak = getattr(self, "_local_turn_speak", True) return local_reply, "local", speak # 2. Hot Memory (alle pinned Punkte) @@ -1618,6 +1661,7 @@ class Agent: pip_packages=arguments.get("pip_packages", []), config_schema=arguments.get("config_schema") or None, fast_patterns=arguments.get("fast_patterns") or None, + speak=bool(arguments.get("speak", False)), author="aria", ) # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt @@ -1677,6 +1721,8 @@ class Agent: for k in ("entry_code", "readme", "description", "args", "active"): if k in arguments and arguments[k] is not None: patch[k] = arguments[k] + if "speak" in arguments and arguments["speak"] is not None: + patch["speak"] = bool(arguments["speak"]) if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): patch["pip_packages"] = arguments["pip_packages"] if "config_schema" in arguments and isinstance(arguments["config_schema"], list): diff --git a/aria-brain/skills.py b/aria-brain/skills.py index 4c9383f..2b9011a 100644 --- a/aria-brain/skills.py +++ b/aria-brain/skills.py @@ -165,6 +165,7 @@ def create_skill( author: str = "aria", config_schema: Optional[list] = None, fast_patterns: Optional[list] = None, + speak: bool = False, ) -> dict: """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. @@ -215,6 +216,11 @@ def create_skill( "author": author, "config_schema": _normalize_config_schema(config_schema), "fast_patterns": _normalize_fast_patterns(fast_patterns), + # speak: soll die Antwort dieses Skills vorgelesen werden (TTS)? + # False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App + # beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen + + # Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung. + "speak": bool(speak), "version_history": [], } write_manifest(name, manifest) @@ -335,10 +341,12 @@ def update_skill(name: str, patch: dict) -> dict: # nach archive_current_version manifest neu laden (version_history geupdatet) manifest = read_manifest(name) or manifest - allowed = {"description", "args", "requires", "active", "version", "entry"} + allowed = {"description", "args", "requires", "active", "version", "entry", "speak"} for k, v in patch.items(): if k in allowed: manifest[k] = v + if "speak" in patch: + manifest["speak"] = bool(patch["speak"]) if "config_schema" in patch: manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) if "fast_patterns" in patch: