Compare commits

..
4 Commits
Author SHA1 Message Date
duffyduck 6cf75644d8 release: bump version to 0.2.1.1 2026-07-11 21:46:56 +02:00
duffyduckandClaude Opus 4.8 2c1de6705b feat(skills): speak-Flag gilt jetzt auch im Claude-Pfad
Vorher gab Claude immer speak=true zurück — führte Claude einen speak=false-
Steuerbefehl aus (z.B. "spiele auf duffy desktop weiter"), wurde die Antwort
trotzdem vorgelesen + 30s. Jetzt konsistent zu Fast-Path/local: führt Claude
einen run_*-Skill mit speak=false erfolgreich aus → speak=false (kein TTS,
App STOP). Der Text landet aber normal in der Bubble (Stefans Wunsch: Text ok,
nur nicht vorlesen). Antwort-Skills (speak=true) + reine Konversation bleiben
gesprochen; bei Skill-Fehler bleibt's gesprochen (Erklaerung soll man hoeren).

Reiner Brain-Fix — App liest speak bereits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:45:27 +02:00
duffyduckandClaude Opus 4.8 2716bc62ff feat(skills): Skill entscheidet selbst ob vorgelesen wird (manifest.speak)
Verallgemeinert das "run_* → stumm"-Heuristik: jeder Skill deklariert im
Manifest ein speak-Flag.
- speak=false (Default) = Steuerbefehl (Spotify, Licht) → kein TTS, App
  beendet direkt (STOP), wie bisher.
- speak=true = Antwort-Skill (Info/Ergebnis) → Antwort wird vorgelesen +
  Gespraechs-Fenster bleibt offen.

Gilt für BEIDE Pfade: Fast-Path (_fast_path_speak aus skill.speak) und
lokale Skill-Ausführung (_local_turn_speak = _skill_speak_flag(run_*)).
Info-Tools (web_search/memory_search/trigger_timer) bleiben gesprochen.

- skills.py: speak in create_skill + update_skill-allowed.
- agent.py: skill_create/skill_update Tool-Schema dokumentiert speak (damit
  ARIA es beim Skill-Bau setzen kann), Dispatch reicht es durch.
- App: _isSilent nur noch speak===false (kein answeredBy=fast-path-Fallback
  mehr, sonst waere ein speak=true-Fast-Path faelschlich stumm).

Bestehende Skills ohne Feld = false = stumm (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:39:55 +02:00
duffyduckandClaude Opus 4.8 a9e1a46a2f feat(local-llm): Skill-Ausführung via local = kein TTS (speak=False)
Wenn das lokale LLM einen echten Skill (run_*, z.B. Spotify) ausführt, ist es
ein Steuerbefehl — genau wie Fast-Path. Jetzt speak=False: kein Vorlesen, und
die App beendet direkt (STOP) statt ins 30s-Gespräch zu gehen. Deckt den Fall
ab, wo Whisper sich verhört ("Nächster Slick") und local statt Fast-Path
den Skill auffängt.

Info-Tools (web_search/memory_search/trigger_timer) zählen NICHT — deren
Antwort/Bestätigung bleibt gesprochen. Reiner Brain-Fix: die App liest speak
bereits (2b48e5c), kein neuer APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:33:17 +02:00
5 changed files with 93 additions and 11 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20100
versionName "0.2.1.0"
versionCode 20101
versionName "0.2.1.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.1.0",
"version": "0.2.1.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+5 -2
View File
@@ -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,
+76 -5
View File
@@ -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,11 +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
# 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
@@ -1273,6 +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_*) 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_speak = self._skill_speak_flag(tname)
if self._local_tool_failed(tname, tresult):
had_error = True
messages.append({"role": "tool",
@@ -1367,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 <voice>-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,
@@ -1383,7 +1436,10 @@ 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:
return local_reply, "local", True # ARIA-Antwort → vorlesen ok
# 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)
hot = self.store.list_pinned()
@@ -1499,6 +1555,12 @@ class Agent:
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = ""
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
# Konversation bleiben gesprochen.
self._claude_turn_speak = True
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools,
@@ -1516,6 +1578,11 @@ class Agent:
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or ""
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
self._claude_turn_speak = self._skill_speak_flag(_tn)
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists
@@ -1584,7 +1651,8 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
return final_reply, "claude", True # ARIA-Antwort → vorlesen ok
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True.
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True))
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1605,6 +1673,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
@@ -1664,6 +1733,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):
+9 -1
View File
@@ -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: