fix: 5er-Bundle — Wake-Word, Spotify-Latenz, File-Limit, Connection-Refused

- WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event-
  Race + setBackground/setForeground statt setResumeCooldown im AppState.
- Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin
  emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr.
- Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track,
  pause, play, lauter, ...) matchen via Regex und gehen direkt aufs
  spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl.
- File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload +
  Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB.
- TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send
  fast-failt bei disconnected RVS statt 30s zu timeouten, und beide
  UIs reloaden automatisch beim Reconnect-Event.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 08:27:08 +02:00
parent 886b4409d2
commit e82e07e3a2
10 changed files with 249 additions and 11 deletions
+132
View File
@@ -782,6 +782,63 @@ META_TOOLS = [
]
# ── Spotify Fast-Path ──────────────────────────────────────────────────
#
# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen
# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline.
# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur
# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call
# wie bisher in die normale chat()-Loop und Claude entscheidet — keine
# Funktionalitaet geht verloren.
#
# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase,
# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber
# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck"
# faelschlich als "previous track" interpretieren.
_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [
# (regex, action, http-method, volume-delta)
# NEXT
(r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$", "next", "POST", None),
(r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$", "next", "POST", None),
# PREVIOUS
(r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$", "previous", "POST", None),
(r"^(zurueck|zurück)$", "previous", "POST", None),
# PAUSE
(r"^(pause|pausiere|pausieren|stop|stopp|halt)$", "pause", "PUT", None),
(r"^(musik|spotify) (pause|aus|stop|stopp)$", "pause", "PUT", None),
# PLAY / RESUME
(r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$", "play", "PUT", None),
(r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$", "play", "PUT", None),
# VOLUME — Delta wird auf den aktuell ermittelten Volume-Wert aufaddiert
(r"^(lauter|musik lauter|spotify lauter|volume hoch|lautstärke hoch)$", "volume", "PUT", 10),
(r"^(leiser|musik leiser|spotify leiser|volume runter|lautstärke runter)$", "volume", "PUT", -10),
(r"^(viel lauter|deutlich lauter)$", "volume", "PUT", 20),
(r"^(viel leiser|deutlich leiser)$", "volume", "PUT", -20),
]
def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]:
"""Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None."""
norm = (text or "").strip().lower()
norm = re.sub(r"[.!?]+$", "", norm)
norm = re.sub(r"\s+", " ", norm)
if not norm:
return None
for rx, action, method, delta in _SPOTIFY_FAST_PATTERNS:
if re.match(rx, norm):
return action, method, delta
return None
def _run_spotify_call(path: str, method: str, body: Optional[dict] = None) -> dict:
"""Fuehrt einen Spotify-Skill-Call aus. Skill-Args: path, method, body (JSON-String).
Returns das run_skill-Ergebnis."""
args: dict = {"path": path, "method": method}
if body is not None:
args["body"] = json.dumps(body)
return skills_mod.run_skill("spotify", args, timeout_sec=15)
def _skill_to_tool(s: dict) -> dict:
"""Mappt einen Skill auf ein OpenAI-Function-Tool."""
args = s.get("args") or []
@@ -849,6 +906,73 @@ class Agent:
self._pending_events = []
return events
def _try_spotify_fast_path(self, user_message: str) -> Optional[str]:
"""Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs
spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird
komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das
spotify-Skill nicht installiert ist (dann faellt's normal in Claude)."""
m = _spotify_fast_match(user_message)
if m is None:
return None
action, method, delta = m
# Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude.
try:
manifest = skills_mod.read_manifest("spotify")
except Exception:
manifest = None
if not manifest or not manifest.get("active", True):
logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude")
return None
logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r",
action, method, delta, user_message[:60])
def _err_reply(label: str, res: dict) -> str:
# ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives
# Gerät) etc. — Skill schreibt den Spotify-Error nach stderr.
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}"
try:
if action == "next":
res = _run_spotify_call("/v1/me/player/next", method)
return "Spotify: nächster Track ⏭" if res.get("ok") else _err_reply("Skip", res)
if action == "previous":
res = _run_spotify_call("/v1/me/player/previous", method)
return "Spotify: vorheriger Track ⏮" if res.get("ok") else _err_reply("Zurück", res)
if action == "pause":
res = _run_spotify_call("/v1/me/player/pause", method)
return "Spotify: pausiert ⏸" if res.get("ok") else _err_reply("Pause", res)
if action == "play":
res = _run_spotify_call("/v1/me/player/play", method)
return "Spotify: spielt ▶" if res.get("ok") else _err_reply("Play", res)
if action == "volume" and delta is not None:
state = _run_spotify_call("/v1/me/player", "GET")
if not state.get("ok"):
return _err_reply("Lautstärke-Status", state)
cur_vol = 50
try:
out = (state.get("stdout") or "").strip()
if out:
data = json.loads(out)
dev = data.get("device") or {}
cur_vol = int(dev.get("volume_percent", 50))
except Exception as exc:
logger.warning("[spotify-fast] volume-state parse: %s", exc)
new_vol = max(0, min(100, cur_vol + delta))
res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT")
if not res.get("ok"):
return _err_reply("Lautstärke", res)
arrow = "🔊" if delta > 0 else "🔉"
return f"Spotify: Lautstärke {new_vol}% {arrow}"
except Exception as exc:
logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s",
action, exc)
return None
return None
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
@@ -861,6 +985,14 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett.
# Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc.
fast_reply = self._try_spotify_fast_path(user_message)
if fast_reply is not None:
self.conversation.add("user", user_message, source=source)
self.conversation.add("assistant", fast_reply)
return fast_reply
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source)