#!/usr/bin/env python3
"""
Wivexx — Background Voice Assistant powered by Claude API
================================================================
* Say "hey wivexx" to activate -- stays in conversation until dismissed
* Say "stop", "stand down", or "that's all" to return to standby
* Interruptible TTS -- speak while Wivexx talks to cut him off
* Web search with context-aware pronoun resolution
* Dual-monitor screen vision, conversation memory
* Runs silently in the system tray -- never steals focus
"""

# Fix stdout encoding FIRST -- Windows console defaults to cp1252 which can't
# handle Unicode box-drawing chars. Force UTF-8 or fall back gracefully.
import sys, os
if sys.stdout is None:
    # Running under pythonw (no console) -- redirect prints to log file
    _stdout_log = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx_stdout.log")
    try:
        sys.stdout = open(_stdout_log, "w", encoding="utf-8", buffering=1)
        sys.stderr = sys.stdout
    except Exception:
        pass
else:
    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
        sys.stderr.reconfigure(encoding="utf-8", errors="replace")
    except (AttributeError, Exception):
        pass

# stdlib only at top (needed before pre-flight) ────────────────────────────────
import argparse
import asyncio
import base64
import difflib
import glob as _glob
import importlib
import io
import json
import logging
import logging.handlers
import math
import os
import random
import re
import shutil
import signal
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import queue
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from typing import Optional

# ── single-instance guard ─────────────────────────────────────────────────────
# Only ONE Wivexx may run at a time. We create a Windows named mutex held for the
# life of this process; if it already exists, another instance is live, so we bail
# out immediately — before the splash and the heavy imports. Windows frees the
# mutex automatically when the process ends (even on a crash), so there's no stale
# lock to clean up. Gated on __main__ so that `import wivexx_api` (e.g. from
# wivexx_webgui, which re-runs this file under the name "wivexx_api") never trips
# it and kills the running app.
_SINGLE_INSTANCE_HANDLE = None


def _enforce_single_instance() -> None:
    global _SINGLE_INSTANCE_HANDLE
    if os.name != "nt":
        return
    try:
        import ctypes
        from ctypes import wintypes
        k32 = ctypes.WinDLL("kernel32", use_last_error=True)
        k32.CreateMutexW.restype = wintypes.HANDLE
        k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
        _SINGLE_INSTANCE_HANDLE = k32.CreateMutexW(None, False, "Local\\WivexxSingleInstance")
        if ctypes.get_last_error() == 183:        # ERROR_ALREADY_EXISTS
            try:
                ctypes.windll.user32.MessageBoxW(
                    0, "Wivexx is already running.", "Wivexx", 0x40)  # MB_ICONINFORMATION
            except Exception:
                pass
            os._exit(0)
    except Exception:
        pass  # never let the guard itself block a normal launch


if __name__ == "__main__":
    _enforce_single_instance()

# ── v1.18: instant launch splash ──────────────────────────────────────────────
# Spawn a tiny stdlib-only splash process NOW — before the heavy imports below
# (Whisper / torch / the Claude client) — so launching Wivexx paints something
# in a fraction of a second instead of ~20 s of nothing.  wivexx_gui writes the
# close-sentinel once the real window is up; the splash also self-closes on a
# safety timeout.  Fully wrapped so a splash failure never affects boot.
_SPLASH_CLOSE = os.path.join(tempfile.gettempdir(), "wivexx_splash.close")


def _close_splash() -> None:
    try:
        open(_SPLASH_CLOSE, "w").close()
    except Exception:
        pass


# Gate on __main__ (NOT a sys.argv check) for the SAME reason as the single-
# instance guard above: wivexx_webgui does `import wivexx_api`, which re-executes
# this whole file under the name "wivexx_api". sys.argv[0] is unchanged in that
# re-import (same process), so the old `_is_entry` check was still True and spawned
# a SECOND splash ON TOP of the already-running control room — which then looped
# and vanished only at its safety timeout. `__name__ == "__main__"` is true only
# for the real launch, so the re-import no longer double-spawns the splash.
if __name__ == "__main__":
    try:
        if os.path.exists(_SPLASH_CLOSE):
            os.remove(_SPLASH_CLOSE)
        _splash_py = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                  "wivexx_splash.py")
        _pyw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
        if not os.path.exists(_pyw):
            _pyw = sys.executable
        if os.path.exists(_splash_py):
            subprocess.Popen([_pyw, _splash_py, _SPLASH_CLOSE],
                             creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
    except Exception:
        pass

# ── CLI args ───────────────────────────────────────────────────────────────────
_arg_parser = argparse.ArgumentParser(add_help=False)
_arg_parser.add_argument("--debug",     action="store_true", default=False)
_arg_parser.add_argument("--list-apps", action="store_true", default=False,
                         dest="list_apps",
                         help="Print all supported app launch names and exit")
_arg_parser.add_argument("--preflight", action="store_true", default=False,
                         dest="force_preflight",
                         help="Force the full dependency / environment check")
_cli_args, _ = _arg_parser.parse_known_args()

# ══════════════════════════════════════════════════════════════════════════════
#  PRE-FLIGHT — check / install all dependencies before heavy imports
# ══════════════════════════════════════════════════════════════════════════════
WIVEXX_VERSION = "1.0"   # official launch (2026-07-11): version reset — the
                         # 1.2x-1.5x builds were the pre-launch beta line

# ── v1.3.1: Local AI memory engine availability flag ──────────────────────────
# Set by _preflight() after pinging the local Ollama server. When False, the
# 3-tier memory engine still logs episodically but skips LLM-based consolidation.
_OLLAMA_AVAILABLE = False
_OLLAMA_BASE_URL  = "http://127.0.0.1:11434"
_OLLAMA_MODEL     = "llama3.2"

_PACKAGES = [
    ("dotenv",            "python-dotenv",    True),
    ("anthropic",         "anthropic",        True),
    ("requests",          "requests",         True),    # HTTP fallback: web search, weather, currency, updates
    ("faster_whisper",    "faster-whisper",   True),
    ("sounddevice",       "sounddevice",      True),
    ("numpy",             "numpy<2",          True),    # ML stack needs numpy 1.x
    ("scipy",             "scipy",            True),    # resampling (hard import)
    ("mss",               "mss",              True),
    ("PIL",               "pillow",           True),
    ("pyttsx3",           "pyttsx3",          True),
    ("psutil",            "psutil",           True),    # v1.6: performance guard
    ("edge_tts",          "edge-tts",         False),
    ("pygame",            "pygame==2.6.1",    False),   # pinned: known-good wheels
    ("pystray",           "pystray",          False),
    ("webview",           "pywebview",         False),   # v1.20: web (WebView2) control-room UI; falls back to tk
    ("duckduckgo_search", "duckduckgo-search",True),
    ("pynput",            "pynput",           False),
    ("keyboard",          "keyboard",         False),
    ("pycaw",             "pycaw",            False),
    ("pythoncom",         "pywin32",          False),
    ("comtypes",          "comtypes",         False),
    ("webrtcvad",         "webrtcvad-wheels==2.0.14", False),  # VAD (falls back to energy-based detection)
    ("moderngl",          "moderngl==5.12.0", False),   # Feature 5: spectrogram (pinned)
    ("spacy",             "spacy",            False),   # NLP intent gatekeeper
    ("fastapi",           "fastapi",          False),   # v1.3.1: local REST API
    ("uvicorn",           "uvicorn",          False),   # v1.3.1: ASGI server
    ("pvporcupine",       "pvporcupine",      False),   # optional dedicated wake-word engine
]

# ── v1.6: pre-flight result cache ──────────────────────────────────────────────
# The full package / network / model check used to run on EVERY boot and cost
# 30–60 s.  Once it passes, a signature is cached and later boots skip straight
# to launch.  Run with  --preflight  to force a full re-check (e.g. after
# editing requirements or moving to a new machine).
_PREFLIGHT_CACHE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), ".wivexx_preflight.json"
)


def _preflight_signature() -> str:
    import hashlib
    raw = repr(_PACKAGES) + sys.version + sys.executable + WIVEXX_VERSION
    return hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()


def _preflight_cache_valid() -> bool:
    try:
        with open(_PREFLIGHT_CACHE, encoding="utf-8") as f:
            return json.load(f).get("signature") == _preflight_signature()
    except Exception:
        return False


def _write_preflight_cache() -> None:
    try:
        with open(_PREFLIGHT_CACHE, "w", encoding="utf-8") as f:
            json.dump({"signature": _preflight_signature(),
                       "checked_at": time.time()}, f)
    except Exception:
        pass


def _check_api_key() -> None:
    """Check for an Anthropic API key. NON-FATAL: Wivexx still boots and runs every
    local tool without one — only Claude conversation is gated until a key is added
    (the user sees a friendly setup guide, in-app and at wivexx.com/setup)."""
    try:
        import wivexx_config as _c  # type: ignore
        if _c.API_KEY and _c.API_KEY not in ("your-api-key-here", ""):
            return
        print("    NOTICE   No Anthropic API key yet — running in LOCAL-ONLY mode.")
        print("             Timers, volume, calculator, clipboard, screenshots and")
        print("             window control work now. For conversation, add a key:")
        print("               1) get one at https://console.anthropic.com/settings/keys")
        print("               2) copy  .env.example → .env  and set ANTHROPIC_API_KEY=…")
        print("               3) restart Wivexx.   Full guide: wivexx.com/setup\n")
    except ImportError:
        print("    ERROR    wivexx_config.py not found — reinstall Wivexx.\n")


def _ping_ollama_async() -> None:
    """Probe the local Ollama server in the background so boot never blocks.
    Updates _OLLAMA_AVAILABLE and the GUI memory indicator when done."""
    def _probe() -> None:
        global _OLLAMA_AVAILABLE
        try:
            urllib.request.urlopen(
                urllib.request.Request(f"{_OLLAMA_BASE_URL}/api/tags"), timeout=3,
            )
            _OLLAMA_AVAILABLE = True
        except Exception:
            _OLLAMA_AVAILABLE = False
        try:
            _gui_mod = sys.modules.get("wivexx_gui")
            if _gui_mod is not None:
                _gui_mod.set_memory_status(_OLLAMA_AVAILABLE)
        except Exception:
            pass
        # v1.38: if there's NO Anthropic key, Ollama becomes the conversational
        # "temporary brain" — pre-warm its chat model so the first question is
        # fast (cold-loading a local LLM can take 10-30 s). Only when it'll be
        # used, so API users never pay the memory cost.
        try:
            if _OLLAMA_AVAILABLE and not _HAS_API:
                _warm_local_brain()
        except Exception:
            pass
    threading.Thread(target=_probe, daemon=True, name="wivexx-ollama-probe").start()


def _warm_local_brain() -> None:
    """Load the local-brain chat model into memory in the background so the first
    real answer isn't slow. No-op if no chat model is available."""
    def _w() -> None:
        try:
            model = _ollama_chat_model()
            if not model:
                return
            body = json.dumps({"model": model, "messages": [{"role": "user", "content": "hi"}],
                               "stream": False, "keep_alive": "30m",
                               "options": {"num_predict": 1}}).encode("utf-8")
            urllib.request.urlopen(urllib.request.Request(
                f"{_OLLAMA_BASE_URL}/api/chat", data=body,
                headers={"Content-Type": "application/json"}), timeout=120)
            _logger.info("Local brain pre-warmed (ollama:%s).", model)
        except Exception as _we:
            _logger.debug("local brain warm-up skipped: %s", _we)
    threading.Thread(target=_w, daemon=True, name="wivexx-brain-warm").start()


def _check_python_version() -> None:
    """Warn (never block) when Python is outside the tested 3.10–3.12 range.
    Newer Python (3.13+) frequently has no prebuilt wheels yet for a few voice
    packages — and occasionally for required ML packages — which is the usual
    cause of install failures on a friend's PC."""
    major, minor = sys.version_info[:2]
    if (3, 10) <= (major, minor) <= (3, 12):
        return
    print("\n  [ Python version ]")
    print(f"    WARN     You're on Python {major}.{minor}. Wivexx is tested on")
    print("             Python 3.10, 3.11 or 3.12 (3.12 recommended).")
    if (major, minor) > (3, 12):
        print("             This version may be too new — some packages have no")
        print("             prebuilt installer for it yet, so the next step can fail")
        print("             or skip optional voice extras. If anything REQUIRED fails,")
        print("             install Python 3.12 (64-bit) and run start_wivexx.bat again.")
    print("             Get 3.12 (64-bit): https://www.python.org/downloads/")


def _preflight() -> None:
    print("=" * 56)
    print(f"  Wivexx v{WIVEXX_VERSION} — Pre-flight Systems Check")
    print("=" * 56)
    _check_python_version()

    # ── Fast path: environment already verified on a previous boot ────────────
    if _preflight_cache_valid() and not _cli_args.force_preflight:
        print("  Fast start — environment verified previously.")
        print("  (run with --preflight to force a full re-check)\n")
        _check_api_key()
        _ping_ollama_async()
        return

    print("\n  [ Packages ]")
    to_install: list[tuple] = []
    for mod, pkg, required in _PACKAGES:
        try:
            importlib.import_module(mod)
            print(f"    OK       {pkg}")
        except ImportError as _imp_err:
            print(f"    MISSING  {pkg}  ({'REQUIRED' if required else 'optional'}) - {_imp_err}")
            to_install.append((mod, pkg, required))

    if to_install:
        print(f"\n  [ Installing {len(to_install)} package(s) ]")
        hard_failures: list[str] = []
        soft_failures: list[str] = []
        for mod, pkg, required in to_install:
            print(f"    -> {pkg} ...", end="", flush=True)
            # --prefer-binary makes pip pick a slightly older version that HAS a
            # prebuilt wheel rather than building the newest sdist from source —
            # the usual reason an install fails on a brand-new Python with no
            # compiler. We don't pass --quiet so the user can see real errors.
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", pkg,
                 "--prefer-binary", "--disable-pip-version-check"],
                capture_output=True,
            )
            if result.returncode == 0:
                try:
                    importlib.import_module(mod)
                    print(" installed [OK]")
                except ImportError as _imp_err:
                    print(f" installed but import failed: {_imp_err}")
                    (hard_failures if required else soft_failures).append(pkg)
            else:
                # show the LAST line of pip's error so the cause isn't a mystery
                err = (result.stderr or b"").decode("utf-8", "replace").strip()
                tail = err.splitlines()[-1][:160] if err else ""
                print(" FAILED [X]" + (f"  ({tail})" if tail else ""))
                (hard_failures if required else soft_failures).append(pkg)

        # OPTIONAL packages that failed never block Wivexx — it degrades gracefully
        # (built-in voice instead of edge-tts, energy-based VAD, no spectrogram).
        if soft_failures:
            print(f"\n  [ NOTE ] These OPTIONAL extras were skipped — Wivexx still runs:")
            for pkg in soft_failures:
                print(f"             - {pkg}")
            print("           (Usually means your Python is newer than these have")
            print("            prebuilt installers for. For every feature, use Python")
            print("            3.10, 3.11 or 3.12 — see the README.)")

        if hard_failures:
            print(f"\n  [ERROR] Could not install required package(s): {', '.join(hard_failures)}")
            print("  This almost always means your Python version is too new for")
            print("  prebuilt packages. Wivexx works best on Python 3.10, 3.11 or 3.12")
            print("  (64-bit). Install one from https://www.python.org/downloads/ ,")
            print('  tick "Add Python to PATH", then delete .wivexx_preflight.json and')
            print("  run start_wivexx.bat again. (Running as Administrator does NOT")
            print("  help here — the package simply has no installer for your Python.)")
            try:
                if sys.stdin and sys.stdin.isatty():
                    input("  Press Enter to exit…")
            except Exception:
                pass
            sys.exit(1)

    # ── spaCy model check ──────────────────────────────────────────────────────
    print("\n  [ NLP Model ]")
    try:
        import spacy as _sp_chk
        _sp_chk.load("en_core_web_sm")
        print("    OK       en_core_web_sm (spaCy)")
    except ImportError:
        print("    SKIP     spaCy not installed — NLP intent filtering disabled")
    except OSError:
        print("    MISSING  en_core_web_sm — downloading...", end="", flush=True)
        _dl = subprocess.run(
            [sys.executable, "-m", "spacy", "download", "en_core_web_sm",
             "--quiet"],
            capture_output=True, timeout=180,
        )
        if _dl.returncode == 0:
            print(" done")
        else:
            print(" FAILED — NLP intent filtering disabled")

    print("\n  [ Network ]")
    online = False
    for test_url in ["https://www.google.com", "https://www.cloudflare.com"]:
        try:
            urllib.request.urlopen(
                urllib.request.Request(test_url, headers={"User-Agent": "Mozilla/5.0"}),
                timeout=5,
            )
            print("    OK       Internet — online")
            online = True
            break
        except Exception:
            pass
    if not online:
        print("    WARN     Internet — offline")

    print("\n  [ Web Search ]")
    if online:
        print("    OK       Anthropic built-in web search (web_search_20250305)")
    else:
        print("    WARN     Offline — web search unavailable until internet restores")

    print("\n  [ Claude API ]")
    _check_api_key()
    print("    OK       API key present")

    # ── Ollama Local AI / Memory Engine — probed in the background (v1.6) ─────
    print("\n  [ Local AI ]")
    print("    ...      Ollama probe running in background (memory engine)")
    _ping_ollama_async()

    _write_preflight_cache()
    print("\n" + "=" * 56)
    print("  All checks passed. Starting Wivexx")
    print("=" * 56 + "\n")

_preflight()

# Console suppression: pythonw.exe already has no console window, so
# FreeConsole() is not needed and is actively harmful — it swallows all
# startup errors before the GUI is visible.  Removed.

# ── Logging setup ──────────────────────────────────────────────────────────────
import datetime as _dt   # needed here for session separator timestamp

_LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx.log")
_log_level = logging.DEBUG if _cli_args.debug else logging.INFO
_logger = logging.getLogger("jarvis")
_logger.setLevel(_log_level)
# Rotating file handler — max 2 MB, keep 3 backups
_fh = logging.handlers.RotatingFileHandler(
    _LOG_FILE, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
_fh.setFormatter(logging.Formatter(
    "%(asctime)s  %(levelname)-8s  [%(funcName)s]  %(message)s"
))
_fh.setLevel(_log_level)
_logger.addHandler(_fh)
# Only add console handler in debug mode (avoids cluttering normal runs)
if _cli_args.debug:
    _ch = logging.StreamHandler()
    _ch.setFormatter(logging.Formatter("%(levelname)-8s [%(funcName)s] %(message)s"))
    _ch.setLevel(logging.DEBUG)
    _logger.addHandler(_ch)

# ── Session separator in log ───────────────────────────────────────────────────
_SESSION_STAMP = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_logger.info("=" * 60)
_logger.info(f"===== WIVEXX SESSION START {_SESSION_STAMP} =====")
_logger.info("=" * 60)
_logger.info(f"Wivexx v{WIVEXX_VERSION} starting — debug={'on' if _cli_args.debug else 'off'}")

# ══════════════════════════════════════════════════════════════════════════════
#  MAIN IMPORTS
# ══════════════════════════════════════════════════════════════════════════════
import wivexx_config as cfg  # type: ignore
import anthropic
import numpy as np
import sounddevice as sd
import mss
from PIL import Image, ImageDraw
# NOTE (boot time): scipy.signal (~5s import) is imported lazily at its single call
# site (_resample_audio), and faster_whisper (~5s import) lazily in _build_stt which
# already runs on the background STT loader thread — neither belongs on the boot path.

_HAS_EDGE_TTS = False
try:
    import edge_tts
    import pygame
    pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=1024)
    pygame.mixer.init()
    _HAS_EDGE_TTS = True
except Exception:
    pass

# pyttsx3 is only the FALLBACK TTS (edge-tts is primary) — initialise lazily
# so its SAPI/COM setup never slows down boot.
_pyttsx3_eng = None
_pyttsx3_lock = threading.Lock()


def _get_pyttsx3():
    global _pyttsx3_eng
    with _pyttsx3_lock:
        if _pyttsx3_eng is None:
            import pyttsx3 as _pyttsx3_mod
            _pyttsx3_eng = _pyttsx3_mod.init()
            _pyttsx3_eng.setProperty("rate", 165)
    return _pyttsx3_eng

_HAS_TRAY = False
try:
    import pystray
    _HAS_TRAY = True
except ImportError:
    pass

_HAS_PYNPUT = False
try:
    import pynput.keyboard as _pynput_kb
    _HAS_PYNPUT = True
except ImportError:
    pass

# ── spaCy NLP (intent gatekeeper) — loaded in the background (v1.6) ──────────
# The en_core_web_sm load takes several seconds; it used to block boot.  All
# call sites already degrade gracefully while _NLP_AVAILABLE is False, so we
# load it on a daemon thread and flip the flag when ready.
_nlp = None
_NLP_AVAILABLE = False


def _load_spacy_bg() -> None:
    global _nlp, _NLP_AVAILABLE
    try:
        import spacy as _spacy_mod
        _nlp = _spacy_mod.load("en_core_web_sm")
        _NLP_AVAILABLE = True
        _logger.info("spaCy NLP gatekeeper ready.")
    except OSError:
        print("[NLP] spaCy model not found. Run: python -m spacy download en_core_web_sm")
    except ImportError:
        print("[NLP] spaCy not installed. Run: pip install spacy")
    except Exception as _sp_e:
        _logger.warning("spaCy background load failed: %s", _sp_e)


threading.Thread(target=_load_spacy_bg, daemon=True, name="wivexx-spacy-load").start()

# ── Constants ──────────────────────────────────────────────────────────────────
def _auto_detect_sample_rate() -> int:
    """Query the active input device for its native sample rate.
    Falls back to 44100 if detection fails (a common mic default)."""
    try:
        device_idx = getattr(cfg, "MIC_DEVICE", None)
        if device_idx is None:
            device_idx = sd.default.device[0]
        dev_info = sd.query_devices(int(device_idx))
        rate = int(dev_info.get("default_samplerate", 44100))
        return rate if rate > 0 else 44100
    except Exception:
        return 44100

SAMPLE_RATE = _auto_detect_sample_rate()
SILENCE_HOLD_SECS = 1.5
MAX_QUESTION_SECS = 45
CALIB_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx_calibration.json")

# A dismiss is a terse, STANDALONE "return to standby" — it must be essentially
# the whole utterance, never a short word buried in conversation. (v1.42: naive
# substring matching used to end the chat on "i can't sleep", "stop the music",
# "i said goodbye…" — see _is_dismiss.)
DISMISS_PHRASES = {
    "stop", "stand down", "stand by", "standby",
    "that's all", "thats all", "that is all",
    "that will be all", "that'll be all", "thatll be all",
    "thank you jarvis", "thank you wivexx", "thanks jarvis", "thanks wivexx",
    "goodbye", "goodbye jarvis", "goodbye wivexx", "good night", "goodnight",
    "go to sleep", "sleep", "dismissed", "you can go", "you can go now",
    "never mind", "nevermind", "at ease",
}
# ── Explicit screenshot phrases (must clearly ask to capture the screen) ──────
SCREEN_PHRASES = [
    "screenshot", "screen shot", "take a screenshot", "take a screen shot",
    "capture my screen", "capture the screen",
    "what's on my screen", "what is on my screen",
    "what's on the screen", "what is on the screen", "what's on screen",
    "what's on my monitor", "what is on my monitor",
    "on my screen right now", "on my screen currently",
    "see my screen", "look at my screen", "show my screen",
    "see what's on my screen", "see what is on my screen",
    "see what's on the screen", "see what is on the screen",
    "see what's right on my screen", "see what is right on my screen",
    "can you see my screen", "can you see what",
    "can you check my screen", "check my screen",
    "look at what's on my screen", "look at what is on my screen",
    "tell me what's on my screen", "tell me what is on my screen",
    "tell me what's on the screen", "tell me what is on the screen",
    "what can you see on my", "what do you see on my",
    "what can you see on the", "what do you see on the",
    "what game am i", "what app am i", "what app am i running",
    "what window is open", "what's open on my pc", "what is open on my pc",
    "what program am i", "what is running on my",
    "what's displayed", "what is displayed on",
    "read my screen", "read what's on my screen",
    "scan my screen", "analyse my screen", "analyze my screen",
    "look at my monitor", "see my monitor",
    "primary monitor", "primary screen", "primary display",
    "main monitor", "main screen", "main display",
    "check my primary monitor", "check my primary screen",
    "check the primary monitor", "check the primary screen",
    "see my primary monitor", "see my primary screen",
    "look at my primary monitor", "look at my primary screen",
    "what's on my primary monitor", "what is on my primary monitor",
    "what's on my primary screen", "what is on my primary screen",
    "what's on the primary monitor", "what is on the primary monitor",
    "my main screen", "my main monitor",
]

BOTH_MONITORS_PHRASES = [
    "both monitors", "both screens", "both displays",
    "show both monitors", "show both screens", "show both displays",
    "see both monitors", "see both screens",
    "check both monitors", "check both screens",
    "capture both monitors", "capture both screens",
    "what's on both monitors", "what is on both monitors",
    "what's on both screens", "what is on both screens",
    "all monitors", "all screens", "all displays",
    "look at both", "check both", "see both",
    "multi monitor", "multi-monitor", "dual monitor", "dual screen",
    "extended display", "extended screen",
]

SECONDARY_MONITOR_PHRASES = [
    "second monitor", "second screen", "second display",
    "secondary monitor", "secondary screen", "secondary display",
    "2nd monitor", "2nd screen", "2nd display",
    "monitor 2", "screen 2", "display 2",
    "other monitor", "other screen", "other display",
    "right monitor", "right screen",
    "left monitor", "left screen",
    "what's on my second monitor", "what is on my second monitor",
    "what's on my second screen", "what is on my second screen",
    "what's on the second monitor", "what is on the second monitor",
    "what's on my other monitor", "what is on my other monitor",
    "what's on my other screen", "what is on my other screen",
    "what's on the other monitor", "what is on the other monitor",
    "see my second monitor", "see my second screen",
    "see my other monitor", "see my other screen",
    "check my second monitor", "check my second screen",
    "check my other monitor", "check my other screen",
    "look at my second monitor", "look at my second screen",
    "look at my other monitor", "look at my other screen",
    "can you see my second monitor", "can you see my secondary monitor",
    "can you see my other monitor", "can you see my other screen",
]

MULTI_MONITOR_PHRASES = BOTH_MONITORS_PHRASES + SECONDARY_MONITOR_PHRASES
SEARCH_TRIGGERS = {
    "latest", "current", "today", "now", "recent", "news", "price", "cost",
    "stock", "weather", "forecast", "score", "result", "who won", "what happened",
    "update", "release", "launched", "announced", "2025", "2026", "this year",
    "this week", "this month", "search", "look up", "find out", "google",
    "how much does", "how many", "who is the", "what is the", "when did",
    "when is", "where is", "is it out",
    # Spanish (v1.34) — the triggers were English-only, so Spanish questions
    # never got the search tool and Claude PROMISED searches it couldn't run
    "actualmente", "a día de hoy", "a dia de hoy", "hoy en día", "hoy en dia",
    "ahora mismo", "esta semana", "este mes", "este año", "en tiempo real",
    "últim", "ultim", "noticias", "precio", "cuánto cuesta", "cuanto cuesta",
    "búscalo", "buscalo", "busca en", "investiga", "quién ganó", "quien gano",
    "qué está pasando", "que esta pasando", "más reciente", "mas reciente",
}

SYSTEM_PROMPT = (
    "You are Wivexx — Ricardo's personal AI assistant for Wivexx Studio. "
    "Speak like a poised, formal AI butler (the Iron Man assistant archetype): "
    "precise, efficient, quietly witty. "
    "Address the user as 'sir'. No preamble, no filler phrases. "
    "CRITICAL COMMAND: You MUST keep your responses incredibly brief. Answer in 2 to 3 short sentences maximum. "
    "NEVER use bullet points, numbered lists, or markdown. Speak in conversational, flowing paragraphs. "
    "You possess a persistent memory system. If facts or past conversation topics "
    "are provided in your prompt, treat them as your own continuous memory. "
    "When recalling memories, weave them naturally into your response instead of listing them, "
    "and ALWAYS answer in the language the user is currently speaking — memories are stored "
    "notes whose language is incidental; translate them naturally, never let them switch "
    "your reply's language. "
    "WEB SEARCH: you cannot browse the web on your own. If answering needs "
    "current or real-time information you don't reliably know (today's news, "
    "prices, live events, weekly game bonuses), reply with EXACTLY the token "
    "[SEARCH] and nothing else — the system will then run a real web search "
    "and re-ask you with results. NEVER promise to search, NEVER say you will "
    "look something up in a moment: either answer from knowledge or output "
    "[SEARCH]. "
    "When the user shares a screenshot, analyse it precisely."
)

# v1.20: self-awareness — fold the live capability catalog into the system prompt
# (once, at load) so Wivexx answers accurately when asked what it can do.
try:
    import wivexx_capabilities as _caps
    SYSTEM_PROMPT = SYSTEM_PROMPT + "\n\n" + _caps.system_prompt_summary()
except Exception:
    pass

# ── TTY / color console (Item 9) ───────────────────────────────────────────────
_IS_TTY = sys.stdout.isatty()
_C = {
    "cyan":   "\033[96m" if _IS_TTY else "",
    "amber":  "\033[93m" if _IS_TTY else "",
    "green":  "\033[92m" if _IS_TTY else "",
    "dim":    "\033[2m"  if _IS_TTY else "",
    "reset":  "\033[0m"  if _IS_TTY else "",
    "bold":   "\033[1m"  if _IS_TTY else "",
}

# Optional rich support (Item 9)
_RICH_CONSOLE = None
try:
    from rich.console import Console as _RichConsole
    _RICH_CONSOLE = _RichConsole()
except ImportError:
    pass

# ── Unified GUI (replaces hud / waveform / spectrogram) ───────────────────────
_wivexx_gui = None
try:
    import wivexx_gui as _wivexx_gui  # type: ignore
except Exception:
    pass

# (legacy hud/waveform/spectrogram overlays removed in v1.9 — wivexx_gui.py
# replaced them back in v1.3; their stub references are gone too)

# ── Globals ────────────────────────────────────────────────────────────────────
_conversation: list[dict] = []
_conversation_log: list[tuple[float, str, str]] = []   # (timestamp, role, text)

# ── Batch D (v1.17): voice biometrics + AEC + screen-click overlay ────────────
_last_utterance = None          # (audio ndarray, rate, ts) — most recent capture
_screen_click_map: dict = {}    # n → screenui item, populated by "number the screen"
try:
    import wivexx_aec as _aec_mod
except Exception:
    _aec_mod = None
_session_start: float = time.time()
_tray_icon = None
_client = anthropic.Anthropic(api_key=cfg.API_KEY)

# ── API-key state (public-build onboarding) ───────────────────────────────────
# Wivexx still boots + runs all LOCAL tools (timers, volume, calculator, clipboard,
# screenshots, window control…) with NO key. Only Claude-backed conversation needs
# one — so when it's missing we never crash or hard-exit; we show a friendly setup
# guide instead. Mirrors the guide on wivexx.com/setup.
_HAS_API = bool(cfg.API_KEY and cfg.API_KEY not in ("", "your-api-key-here"))


# ── v1.28: on-demand translator for wivexx_i18n ───────────────────────────────
# Guarantees FULL localization coverage: any fixed spoken line or tk label not in
# wivexx_i18n's hand dictionary is translated by a fast Claude call and cached, so
# nothing Wivexx says or shows stays English when a non-English language is chosen.
def _translate_fixed_line(text: str, lang: str) -> str:
    if not _HAS_API:
        return ""
    names = {"es": "Spanish", "en": "English"}
    target = names.get(lang, lang)
    try:
        resp = _client.messages.create(
            model="claude-haiku-4-5-20251001",          # fast + cheap for one line
            max_tokens=220,
            system=(f"You translate a short line from a poised, formal AI butler assistant "
                    f"into {target}. If the line is ALREADY written in {target}, return it "
                    f"EXACTLY as given, unchanged — never translate it into another "
                    f"language. Keep the same concise, formal tone. NEVER translate "
                    f"proper names, app names, brand names, file names or usernames — "
                    f"keep them EXACTLY as written (e.g. Steam stays Steam, never "
                    f"'El vapor'; Discord stays Discord). Preserve numbers and "
                    f"placeholders exactly. Reply with ONLY the translation — no quotes, "
                    f"no explanation."),
            messages=[{"role": "user", "content": text[:500]}],
        )
        return "".join(getattr(b, "text", "") for b in resp.content).strip()
    except Exception as _tr_e:
        _logger.debug("fixed-line translate failed: %s", _tr_e)
        return ""


try:
    import wivexx_i18n as _i18n_mod
    _i18n_mod.set_translator(_translate_fixed_line)
except Exception:
    pass


# ── v1.34: multilingual COMMANDS ──────────────────────────────────────────────
# The command waterfall matches English; a Spanish "Abre Steam" used to fall
# through to Claude, which then FAKED the action in prose. Non-English turns
# are now canonicalized to English before matching: the offline dictionary in
# wivexx_i18n covers the common commands instantly; everything else goes
# through one fast cached Claude call that PRESERVES names (Steam ≠ El vapor).
_TURN_DISPLAY_TEXT = ""     # chat feed shows the user's ORIGINAL words


# Spanish compound conjunctions — "abre discord y sube el volumen". Split here
# because the WHOLE-string canonicalization greedily eats the tail ("open discord
# y sube el volumen"), so the English "… and …" splitter never fires.
_ES_COMPOUND_SPLIT_RE = re.compile(
    r"\s+y\s+luego\s+|\s+y\s+despu[eé]s(?:\s+de\s+eso)?\s+|\s+luego\s+|\s+y\s+",
    re.IGNORECASE)


def _split_spanish_compound(text: str, _i18n) -> Optional[str]:
    """If a Spanish utterance is two+ commands joined by 'y'/'luego', canonicalize
    each half and re-join with 'and' so `_split_compound_command` runs both. Only
    when EVERY fragment independently canonicalizes to a command — anchored
    patterns mean conversational 'y' ('mi hermano y yo') and number words
    ('treinta y cinco minutos', whose 'cinco minutos' isn't a command) never
    trigger a split."""
    parts = [p.strip() for p in _ES_COMPOUND_SPLIT_RE.split(text.strip()) if p.strip()]
    if len(parts) < 2:
        return None
    canon = []
    for p in parts:
        c = _i18n.quick_english(p)
        if not c:
            return None
        canon.append(c)
    # Only produce a compound the downstream `_split_compound_command` will
    # actually accept (every half starts with a compound verb) — otherwise we'd
    # turn a Spanish chat-ish turn ("sube el volumen y cuéntame un chiste") into
    # an unsplit English string that does a partial action. Fall through instead.
    if not all(c.split() and c.split()[0].lower() in _COMPOUND_VERBS for c in canon):
        return None
    return " and ".join(canon)


def _to_english_for_commands(text: str) -> Optional[str]:
    try:
        import wivexx_i18n as _i18n
        compound = _split_spanish_compound(text, _i18n)
        if compound:
            return compound
        q = _i18n.quick_english(text)
        if q:
            return q
        cached = _i18n.cmd_cache_get(text)
        if cached:
            return cached
    except Exception:
        pass
    if not _HAS_API:
        return None
    try:
        resp = _client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=200,
            system=("Translate the user's message to English so a voice assistant "
                    "can match it against its command patterns. PRESERVE proper "
                    "names, app names, brand names, file names, usernames and "
                    "numbers EXACTLY as written (Steam, Discord, Ana, informe.pdf…). "
                    "Reply ONLY with the English translation — no quotes, nothing else."),
            messages=[{"role": "user", "content": text[:400]}],
        )
        en = "".join(getattr(b, "text", "") for b in resp.content).strip()
        if en:
            import wivexx_i18n as _i18n
            _i18n.cmd_cache_put(text, en)
            return en
    except Exception as _cte:
        _logger.debug("command translation failed: %s", _cte)
    return None


# ── v1.34: conversational command context ─────────────────────────────────────
# "open discord" … chat … "actually close it" — the pronoun now resolves to the
# last app Wivexx opened/closed (fresh for 5 minutes). Without this, "close it"
# fell through to Claude, which pretended to comply.
_LAST_APP = {"name": "", "ts": 0.0}


def _remember_app(name: str) -> None:
    if name:
        _LAST_APP["name"] = name.strip()
        _LAST_APP["ts"] = time.time()


def _resolve_app_pronoun(q: str) -> Optional[str]:
    """'close it' / 'open that again' → 'close discord' via the last-app context."""
    if not _LAST_APP["name"] or time.time() - _LAST_APP["ts"] > 300:
        return None
    m = re.match(
        r"^(?:please\s+|wivexx[,\s]+|actually\s+|ok(?:ay)?\s+)*"
        r"(open|launch|start|close|quit|kill|exit|reopen)\s+"
        r"(?:it|that|that\s+app|the\s+app)"
        r"(?:\s+(?:again|back(?:\s+up)?|please|now))*[.!\s]*$",
        q, re.IGNORECASE)
    if not m:
        return None
    verb = "open" if m.group(1).lower() in ("reopen",) else m.group(1).lower()
    return f"{verb} {_LAST_APP['name']}"


# v1.34: compound commands — "take a screenshot and set a timer for 5 minutes"
# used to run only the FIRST command; the second silently vanished. When both
# halves of an "… and …" utterance start with a command verb, each half runs
# through the pipeline as its own turn.
_COMPOUND_VERBS = {
    "open", "close", "launch", "start", "quit", "kill", "set", "take",
    "capture", "play", "pause", "resume", "skip", "stop", "mute", "unmute",
    "turn", "switch", "change", "remind", "remember", "note", "search",
    "show", "lock", "run", "test", "free", "cancel", "shut", "next",
    "volume", "increase", "decrease", "raise", "lower", "activate",
    "deactivate", "enable", "disable", "flip", "roll", "toss",
}


def _split_compound_command(q: str) -> Optional[tuple]:
    m = re.match(r"^(.{3,70}?)\s+(?:and\s+(?:then\s+|also\s+)?|then\s+)(.{3,90})$",
                 q.strip(), re.IGNORECASE)
    if not m:
        return None
    a, b = m.group(1).strip(), m.group(2).strip()
    try:
        wa, wb = a.split()[0].lower(), b.split()[0].lower()
    except IndexError:
        return None
    if wa in _COMPOUND_VERBS and wb in _COMPOUND_VERBS:
        return a, b
    return None


# v1.50: "mute"/"unmute" only ever mean SYSTEM audio. A mute whose object is a
# GAME / voice-chat / person target ("mute the enemy pings", "mute comms", "mute
# that guy", "mute the mic") must NOT toggle system output — the user is talking
# about the game or a meeting mic Wivexx can't touch (its only mic toggle is its
# OWN listening, Ctrl+Shift+M). This negative-lookahead body is the single source
# of truth shared by the inline extractor AND _VOLUME_RE so both reject the exact
# same non-system objects. Bare "mute" / "mute the sound|audio|volume|pc|…" still
# fire (nothing to reject → lookahead passes).
_MUTE_NOT_SYSTEM = (
    r"(?!\s+(?:the\s+|my\s+|a\s+|that\s+|this\s+|his\s+|her\s+|their\s+|your\s+)?"
    r"(?:mic|microphone|enemy|enemies|ping|pings|comm|comms|chat|channel|"
    r"notifications?|voice|team|teammates?|stream|streamer|lobby|everyone|"
    r"everybody|him|her|them|they|guy|guys|players?|discord|spotify|browser|"
    r"tabs?|someone|somebody|myself|people|kids?|bots?)\b)"
)


# ── v1.42: inline / mid-sentence commands during conversation ─────────────────
# A user talks normally AND drops in a quick control ("...man this boss is
# brutal, turn up the volume, got any tips?"). Before this, the whole turn went
# one way — either the intent gate called it "chat" (the control silently never
# ran) or the waterfall ran on the full string (the conversation was lost). Now
# we pull the embedded control(s) out, run them, and converse with the leftover
# so the chat keeps flowing.
#
# HIGH PRECISION is the whole point: stealing a chunk of real conversation and
# running it as a command is far worse than missing an embedded command (which
# just stays harmless in the chat). So we only recognize a CURATED set of the
# quick-controls people actually interleave into speech, only at a clause
# boundary, and only when there is genuine leftover conversation. Operates on
# the already-English-canonicalized turn text (Spanish handled upstream in a
# later pass); returns None for pure chat AND for pure commands (those keep
# their existing waterfall / compound-split path).
_INLINE_CMD_PATTERNS = [
    # volume
    r"turn (?:it|the volume|the sound|the music) (?:up|down)(?: a bit| a little| all the way| please)?",
    r"turn (?:up|down) the (?:volume|sound|music)",
    r"volume (?:up|down)",
    r"(?:crank|bump|kick|pump) (?:it|the volume|the music|things) up(?: a bit| a little)?",
    r"(?:make it |a (?:bit|little) )?louder",
    r"(?:make it |a (?:bit|little) )?quieter",
    r"(?:raise|lower|drop|increase|decrease) the (?:volume|sound)",
    r"(?:max|maximum|full) volume",
    r"set (?:the )?volume to \d{1,3}(?: ?percent| ?%)?",
    # mute — system audio only. A mute whose object is a game / voice-chat / person
    # target ("mute the enemy pings", "mute comms", "mute the mic") stays in chat —
    # see _MUTE_NOT_SYSTEM (shared with _VOLUME_RE) for the full non-system list.
    r"mute" + _MUTE_NOT_SYSTEM + r"(?: the (?:music|volume|sound|audio))?",
    r"unmute" + _MUTE_NOT_SYSTEM + r"(?: the (?:music|volume|sound|audio))?",
    r"silence (?:the (?:music|audio|sound)|everything)",
    # media transport
    r"pause(?:\s+the\s+(?:music|song|video|track|playback|media))?(?!\s+the\b)",
    r"resume(?: the (?:music|song|video|track|playback))?",
    r"(?:un)?pause it",
    # only 'play/resume (the) music|song' — maps to the play/pause media key.
    # 'play some lofi/jazz/a playlist' can't be honored without a music-content
    # integration, so it stays in chat (the brain answers honestly) rather than
    # extracting a command that presses play-nothing.
    r"(?:play|resume) (?:the )?(?:music|song|playback)",
    r"skip(?: (?:this|the)(?: song| track| one)?| to the next(?: song| track)?)?",
    r"next (?:song|track|one)",
    r"(?:go back to the |play the )?previous (?:song|track)",
    r"stop the (?:music|song|video|playback)",
    # screenshot
    r"(?:take|grab|snap|capture)(?: me)? a screenshot(?: of (?:both monitors|the screen|everything))?",
    r"screenshot(?: both monitors| the screen)?",
    # timer / alarm / reminder — the duration is a real time span, bounded so a
    # following "… and mute the sound" is NOT swallowed into the timer (it used to
    # match [\w ]{1,30} greedily and eat the next command).
    r"(?:set|start) (?:a |an )?(?:timer|alarm) for (?:\d+|an?|half\s+an?|a\s+couple\s+of|a\s+few)\s*(?:second|sec|minute|min|hour|hr)s?",
    r"start (?:a |an )?\d[\w ]{0,20}?(?:timer|alarm)",
    r"remind me in (?:\d+|an?|half\s+an?|a\s+couple\s+of|a\s+few)\s*(?:second|sec|minute|min|hour|hr)s?",
    r"remind me to [\w ]{1,40}",
    # NOTE: brightness (brighten/dim) intentionally NOT here — no waterfall handler
    # exists yet, so dispatching it would fall to the brain and dishonestly claim
    # success. Add the patterns back once a real brightness command lands.
    # apps / system (guarded: an app-ish token, and NOT 'open up' / 'close the deal')
    r"open (?!up\b)(?:my |the )?[a-z0-9][\w.+-]{1,24}",
    r"close (?!(?:up|off|the deal|in)\b)(?:my |the )?[a-z0-9][\w.+-]{1,24}",
    r"launch (?:my |the )?[a-z0-9][\w.+-]{1,24}",
    r"lock (?:the |my )?(?:pc|screen|computer)",
]
# A command span counts only at a clause boundary: string start, right after
# sentence punctuation, or right after a discourse-marker / conjunction.
_INLINE_LEAD = (
    r"(?:^|(?<=[,;:.!?])|\b(?:and|but|so|then|also|plus|oh|hey|"
    r"anyway|anyhow|actually|meanwhile|wait|ok|okay|real quick|"
    r"quick|by the way|btw|while you'?re at it|could you|can you|"
    r"would you|please|also can you|and could you)\b)"
)
_INLINE_STRAND = (
    r"and then|and also|by the way|btw|real quick|while you'?re at it|"
    r"for me|please|and|but|so|then|also|plus|oh|hey|anyway|anyhow|"
    r"actually|meanwhile|wait|ok|okay|could you|can you|would you"
)
_INLINE_RE = re.compile(
    _INLINE_LEAD + r"[\s,]*(?P<cmd>(?:" + "|".join(_INLINE_CMD_PATTERNS) + r"))\b[\s,]*",
    re.IGNORECASE,
)
_INLINE_WORD = re.compile(r"[a-z0-9']+", re.IGNORECASE)
_INLINE_TRIM = re.compile(
    r"^(?:[\s,;:.!?-]+|(?:and|but|so|then|also|plus|oh|hey|anyway|anyhow|"
    r"actually|meanwhile|wait|ok|okay|by the way|btw|please|um|uh|and then|"
    r"real quick|quick)\b[\s,]*)+", re.IGNORECASE)
_INLINE_TRIM_END = re.compile(
    r"(?:[\s,;:.!-]+|\b(?:and|but|so|then|also|plus|oh|hey|anyway|actually|"
    r"meanwhile|wait|ok|okay|by the way|btw|please|and then)\b[\s,]*)+$",
    re.IGNORECASE)
_INLINE_STRAND_MID = re.compile(
    r"(?:^|(?<=[,;:.!?]))\s*(?:" + _INLINE_STRAND + r")\s*(?=[,;:.!?]|$)",
    re.IGNORECASE)


def _inline_app_known(cmd: str) -> bool:
    """For an inline 'open/close/launch X' span, True only if X resolves to a REAL
    app in _APP_REGISTRY (mirrors _open_app's exact + substring match). Gaming /
    among-conversation callouts — "open mid", "push top", "close the gap", "open
    dragon" — name no app, so without this they'd try to launch/kill a nonexistent
    app and derail the chat. Non open/close/launch spans return True (gate N/A).
    Standalone "open X" commands are unaffected — they take the full waterfall +
    Start-Menu auto-discovery, not this high-precision inline-in-conversation path."""
    m = re.match(r"(?:open|close|launch)\s+(?:my\s+|the\s+)?(.+)$", cmd, re.IGNORECASE)
    if not m:
        return True
    name = m.group(1).strip(" .,!?").lower()
    if not name:
        return False
    # Pronoun follow-ups ("close it", "open that") resolve to the LAST app, and bulk
    # targets ("close everything/all") are real commands — never gaming callouts.
    if name in ("it", "this", "that", "them", "those", "these",
                "everything", "all", "eso", "esa", "esos", "esas", "todo", "todas"):
        return True
    if name in _APP_REGISTRY:
        return True
    return any(variant in name for variant in _APP_REGISTRY if len(variant) >= 3)


def _extract_inline_commands(text: str) -> Optional[dict]:
    """If `text` is conversation with one or more embedded quick-controls,
    return {"commands": [...], "remainder": "<leftover chat>"}; else None."""
    raw = (text or "").strip()
    if not raw:
        return None
    cmds, spans = [], []
    for m in _INLINE_RE.finditer(raw):
        cmd = m.group("cmd").strip(" ,.;:")
        if not _inline_app_known(cmd):
            continue   # "open mid" / "close the gap" name no app → leave it in chat
        cmds.append(cmd)
        spans.append((m.start("cmd"), m.end("cmd")))
    if not cmds:
        return None
    out, last = [], 0
    for s, e in spans:
        out.append(raw[last:s]); last = e
    out.append(raw[last:])
    remainder = " ".join(p.strip() for p in out if p.strip())
    for _ in range(4):
        before = remainder
        remainder = re.sub(r"\s*,\s*(?=,)", "", remainder)
        remainder = _INLINE_STRAND_MID.sub(" ", remainder)
        remainder = re.sub(r"\s*([,;:])\s*(?=[,;:.!?])", "", remainder)
        remainder = re.sub(r"\s{2,}", " ", remainder).strip()
        if remainder == before:
            break
    remainder = _INLINE_TRIM.sub("", remainder)
    remainder = _INLINE_TRIM_END.sub("", remainder)
    remainder = re.sub(r"\s+([,.?!])", r"\1", remainder)
    remainder = re.sub(r"\s{2,}", " ", remainder).strip(" ,.;:-")
    # Require REAL leftover conversation, else it's a pure/compound command.
    words = _INLINE_WORD.findall(remainder)
    if not (("?" in remainder) or (len(words) >= 3)):
        return None
    return {"commands": cmds, "remainder": remainder}


# ── v1.42: Spanish inline / mid-sentence commands ────────────────────────────
# The English extractor above works on English-canonicalized text; a Spanish
# MIXED turn ("este jefe es imposible, sube el volumen, ¿algún consejo?") never
# canonicalizes as a whole (quick_english returns None on the full string), so
# its embedded command was lost. Here we segment the Spanish turn on clause
# boundaries and use quick_english PER FRAGMENT as the command test — it is
# anchored (^…$) and only matches real commands, so a truthy result is a clean,
# high-precision "this fragment is a command" signal (chat fragments → None).
# We dispatch the cleaned SPANISH fragment (not its English canonical) so the
# command's own confirmation localizes to Spanish; the leftover Spanish chat is
# conversed. NOT split on bare " y " (protects "treinta y cinco minutos").
_ES_INLINE_SPLIT_RE = re.compile(
    r"\s*[,;]\s*|"
    r"\s+y\s+luego\s+|\s+y\s+despu[eé]s(?:\s+de\s+eso)?\s+|\s+luego\s+|"
    r"\s+pero\s+|\s+y\s+tambi[eé]n\s+|\s+adem[aá]s\s+|"
    r"\s+oye\s+|\s+oh\s+|\s+ah\s+|\s+bueno\s+|\s+pues\s+",
    re.IGNORECASE)
_ES_INLINE_TRIM = re.compile(
    r"^(?:[\s,;:.!?¿¡-]+|(?:pero|y|oye|oh|ah|bueno|pues|luego|entonces|"
    r"por\s+favor|o\s+sea)\b[\s,]*)+", re.IGNORECASE)
_ES_INLINE_TRIM_END = re.compile(
    r"(?:[\s,;:.!¿¡-]+|\b(?:pero|y|oye|oh|ah|bueno|pues|luego|entonces|"
    r"por\s+favor)\b[\s,]*)+$", re.IGNORECASE)
_ES_FRAG_LEAD = re.compile(
    r"^(?:(?:oye|oh|ah|eh|bueno|pues|mira|escucha|y|o\s+sea)\b[\s,]*)+", re.IGNORECASE)
_ES_FRAG_TAIL = re.compile(
    r"[\s,]*(?:por\s+favor|porfa|porfis|tantito|un\s+poquito|un\s+poco|"
    r"un\s+momento|ahora|ya|please)\b[.!\s]*$", re.IGNORECASE)
_ES_INLINE_WORD = re.compile(r"[a-záéíóúüñ0-9']+", re.IGNORECASE)


def _extract_inline_commands_es(text: str) -> Optional[dict]:
    """Spanish counterpart of `_extract_inline_commands`. Returns
    {"commands": [<cleaned spanish>...], "remainder": "<spanish chat>"} or None."""
    try:
        import wivexx_i18n as _i18n
    except Exception:
        return None
    raw = (text or "").strip()
    frags = [f.strip() for f in _ES_INLINE_SPLIT_RE.split(raw) if f and f.strip()]
    if len(frags) < 2:
        return None
    commands, chat = [], []
    for f in frags:
        cleaned = _ES_FRAG_TAIL.sub("", _ES_FRAG_LEAD.sub("", f)).strip(" ,.;:!¿¡")
        try:
            c = _i18n.quick_english(cleaned) if cleaned else None
        except Exception:
            c = None
        if (c and c.split() and c.split()[0].lower() in _COMPOUND_VERBS
                and _inline_app_known(c)):   # v1.52: "abre mid" names no app → chat
            commands.append(cleaned)      # dispatch cleaned Spanish (localizes)
        else:
            chat.append(f)
    if not commands:
        return None
    remainder = ", ".join(chat)
    remainder = _ES_INLINE_TRIM.sub("", remainder)
    remainder = _ES_INLINE_TRIM_END.sub("", remainder)
    remainder = re.sub(r"\s{2,}", " ", remainder).strip(" ,.;:-¿¡")
    words = _ES_INLINE_WORD.findall(remainder)
    # v1.43: if the leftover is just a SHORT justification ("activa el modo
    # trabajo, necesito concentrarme"), there's no real chat to converse — but we
    # must still RUN the command (EN tolerates this because its waterfall regexes
    # are unanchored .search; anchored ES canonicalization can't, so it used to
    # miss entirely). Empty remainder signals the caller to run-and-stay-silent.
    if not (("?" in remainder) or (len(words) >= 3)):
        remainder = ""
    return {"commands": commands, "remainder": remainder}


class _NoApiKey(Exception):
    """Raised when a Claude call is attempted without an API key configured."""


NO_API_MSG = (
    "I'd love to answer that, but I'm not connected to a brain yet. You have two "
    "free options, sir:\n"
    "  • BEST answers (Claude): go to console.anthropic.com, create an API key, "
    "then in your Wivexx folder copy “.env.example” to “.env”, paste the key after "
    "ANTHROPIC_API_KEY=, and restart Wivexx.\n"
    "  • FREE offline brain (no key, no cost): install Ollama from ollama.com, "
    "then run “ollama pull llama3.2” once — I'll chat locally on your own PC.\n"
    "Either way, all my local tools — timers, volume, apps, screenshots, memory, "
    "window control — already work right now. Step-by-step: wivexx.com/setup"
)
_mic_muted              = False
_audio_muted            = False
_last_mic_toggle        = 0.0
_last_audio_toggle      = 0.0
_mic_was_muted_before_pause = False
_wake_cooldown_until    = 0.0
_last_tts_text: str     = ""

# ── v1.6: call mode / push-to-talk state ──────────────────────────────────────
_call_mode              = False              # True → wake word off, PTT only
_ptt_down               = threading.Event()  # set while the PTT combo is held
_ptt_recording          = False              # True while the PTT capture runs
_ptt_busy               = threading.Lock()

# v1.11: True only while a wake-word conversation is running.  Barge-in is
# limited to this window — talking over the boot greeting or a background
# announcement no longer mutes WIVEXX mid-sentence.
_in_active_session      = False

# ── Timer / flash globals ──────────────────────────────────────────────────────
_timer_done_queue: queue.Queue = queue.Queue()   # labels of fired timers
_TIMER_ALERT_SCRIPT = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "_timer_alert.py"
)
_flash_counter: int = 0
_flash_lock = threading.Lock()

# v1.3.1: serialises _handle_turn across the voice loop and the REST API thread.
# Re-entrant so nested turn handling on a single thread never self-deadlocks.
_TURN_LOCK = threading.RLock()


def _next_flash_id() -> int:
    """Thread-safe counter so every Win32 window class name is unique."""
    global _flash_counter
    with _flash_lock:
        _flash_counter += 1
        return _flash_counter


# ── Wake word phonetic variants ────────────────────────────────────────────────
# Brand wake phrase is "hey wivexx".  "Wivexx" is a coined word, so Whisper hears
# it many ways — cast a wide net of spellings + the "hey/ok/yo" lead-ins.  The old
# "jarvis" set is kept as a quiet fallback so the previous habit still wakes it.
# Phonetic variants of the brand wake word "wivexx" (the ones shown in the
# config UI). It's a coined word, so people — and Whisper — say/hear it many
# ways: "wybexx", "wibex", "why-vex", etc. Add more in the Wake Words editor.
# v1.57: pruned the variants that collide with ORDINARY two-word speech or real
# products — "we vex", "we vexx", "we bex", "wee vex", "wave x", "with vex", "why vex",
# "why vexx", "weave x", "weave ex", "we vics", "webex" (+ their "hey …" forms). These
# fired on normal talk. The coined SINGLE-token spellings ("wevex", "wibex", "wybex" …)
# are kept — they aren't real words — and the boundary rule in _is_wake_word now also
# requires the wake word at the START of the utterance, so a stray lookalike mid-
# sentence no longer wakes it.
_WAKE_VARIANTS_WIVEXX = {
    # canonical + punctuation
    "wivexx", "wivexx,", "wivexx.", "wivexx!", "wivex", "wivex.", "wivexs",
    # common Whisper mishears of "wivexx" (coined single tokens only)
    "wevex", "wevexx", "vivex", "vivexx",
    "wiv x", "wiivex", "wuivex", "wivix", "wivox",
    # the "wy-/wi-bex" family (how it's commonly pronounced)
    "wybexx", "wybex", "wybexs", "wibex", "wibexx", "wibexs",
    "why bex", "wai bex", "wai vex", "wee bex", "wybix", "wibix", "wybux", "wybexc",
    # with lead-ins
    "hey wivexx", "hey wivex", "hey vivex",
    "okay wivexx", "ok wivexx", "ok wivex", "okay wivex", "hi wivexx",
    "yo wivexx", "hey wivexs",
    "hey wybexx", "hey wybex", "hey wibex", "hey wibexx",
    "ok wybexx", "okay wybexx", "ok wibex", "yo wybexx",
}
# Legacy "jarvis" set — a quiet fallback so the old habit still wakes it.
_WAKE_VARIANTS_LEGACY = {
    "jarvis", "jarvas", "jarves", "jarvis,", "jarvis.", "jarvis!",
    "hey jarvis", "hey jarvas", "okay jarvis", "ok jarvis",
    "hi jarvis", "yo jarvis", "j.a.r.v.i.s", "j.a.r.v.i.s.",
}
_WAKE_VARIANTS = _WAKE_VARIANTS_WIVEXX | _WAKE_VARIANTS_LEGACY

# ── User-added wake words (editable in the config UI) ──────────────────────────
_WAKE_WORDS_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "wivexx_wake_words.json")
_custom_wake = {"mtime": -1.0, "words": set()}


def _load_custom_wake() -> set:
    """User wake words from wivexx_wake_words.json (cached; reloaded on change)."""
    try:
        mt = os.path.getmtime(_WAKE_WORDS_FILE)
    except OSError:
        _custom_wake["mtime"] = -1.0
        _custom_wake["words"] = set()
        return _custom_wake["words"]
    if mt != _custom_wake["mtime"]:
        try:
            with open(_WAKE_WORDS_FILE, encoding="utf-8") as f:
                data = json.load(f)
            raw = data.get("words", []) if isinstance(data, dict) else data
            _custom_wake["words"] = {str(w).lower().strip()
                                     for w in (raw or []) if str(w).strip()}
        except Exception:
            _custom_wake["words"] = set()
        _custom_wake["mtime"] = mt
    return _custom_wake["words"]


# ── v1.58: personalized wake profile ("tune the wake word") ────────────────────
# The owner's OWN renderings of the wake word — captured by the guided
# enrollment in _enroll_wake_profile, per language — become first-class wake
# variants + fuzzy references. See wivexx_wake_profile.py.
def _wake_profile_words() -> set:
    """The owner's enrolled SINGLE-TOKEN renderings ('vixx', 'wibics') as literal
    wake variants (boundary-protected in _is_wake_word). MULTI-word renderings
    ('we fix', 'hey why mix') are deliberately excluded here — as substrings they
    would wake inside ordinary sentences ('we fix the bug'); they are matched
    instead via _wake_profile_refs through the length-gated opener/bare path.
    Fail-safe: empty set when no profile / module missing."""
    try:
        import wivexx_wake_profile as _wp
        return {r for r in _wp.renderings() if " " not in r}
    except Exception:
        return set()


def _wake_profile_refs() -> tuple:
    """Glued fuzzy references from the owner's renderings ('whymix', 'ybix')."""
    try:
        import wivexx_wake_profile as _wp
        return _wp.refs()
    except Exception:
        return ()


def get_custom_wake_words() -> list:
    return sorted(_load_custom_wake())


def _save_custom_wake(words) -> bool:
    try:
        clean = sorted({str(w).lower().strip(" .,!?")
                        for w in words if str(w).strip()})
        with open(_WAKE_WORDS_FILE, "w", encoding="utf-8") as f:
            json.dump({"words": clean}, f, indent=2)
        _custom_wake["mtime"] = -1.0          # force a reload on the next check
        return True
    except Exception as e:
        if _logger:
            _logger.warning("wake words: could not save: %s", e)
        return False


def add_wake_word(word: str) -> dict:
    w = (word or "").lower().strip(" .,!?")
    if len(w) < 2:
        return {"ok": False, "error": "Type a word or short phrase (2+ letters)."}
    if w in _WAKE_VARIANTS:
        return {"ok": True, "note": "already recognised"}
    words = set(get_custom_wake_words())
    words.add(w)
    return {"ok": _save_custom_wake(words)}


def remove_wake_word(word: str) -> dict:
    words = set(get_custom_wake_words())
    words.discard((word or "").lower().strip())
    return {"ok": _save_custom_wake(words)}


def wake_words_info() -> dict:
    """Snapshot for the config UI: the canonical phrase, the built-in 'wivexx'
    variants, and the user's custom words."""
    return {
        "wake_word": cfg.WAKE_WORD,
        "display": cfg.WAKE_DISPLAY,
        "defaults": sorted(_WAKE_VARIANTS_WIVEXX),
        "custom": get_custom_wake_words(),
    }

# ══════════════════════════════════════════════════════════════════════════════
#  WHISPER SPEECH-TO-TEXT — dual-profile engine manager (v1.6)
#  "full" profile → WHISPER_FULL_MODEL on CUDA (falls back to lite on CPU)
#  "lite" profile → WHISPER_LITE_MODEL on CPU/int8 (low-power mode)
#  The model loads on a background thread so boot is no longer blocked by it;
#  _transcribe() waits on _STT_READY and serialises access through _STT_LOCK.
# ══════════════════════════════════════════════════════════════════════════════
_STT = None
_STT_PROFILE = ""                    # "full" | "lite" (actually loaded)
_STT_LOCK   = threading.Lock()       # serialises transcribe calls + model swaps
_STT_READY  = threading.Event()
# v1.38: a dedicated TINY model + lock used ONLY for wake-word detection, so the
# wake word is recognised near-instantly (the full/lite _STT still handles the
# actual command for accuracy). Its own lock → wake detection never waits behind
# a long command transcription.
_WAKE_STT = None
_WAKE_STT_LOCK  = threading.Lock()
_WAKE_STT_READY = threading.Event()
_LOW_POWER_MODE = False
_CURRENT_ACTIVITY = ""               # what the mascot is "doing" (gaming/video/…)
_CURRENT_ACTIVITIES = ""             # full comma set (combos) for the wallpaper


def _build_stt(profile: str):
    # Runs on the background STT loader thread — importing faster_whisper here keeps
    # its ~5s import (ctranslate2 DLLs) off the boot path entirely.
    from faster_whisper import WhisperModel
    # Pick English-only or multilingual models based on the chosen language so a
    # Spanish / auto-detect user can actually be understood (the .en models can't).
    try:
        import wivexx_i18n as _i18n
        full_name, lite_name = _i18n.whisper_models()
    except Exception:
        full_name = getattr(cfg, "WHISPER_FULL_MODEL", "medium.en")
        lite_name = getattr(cfg, "WHISPER_LITE_MODEL", "small.en")
    if profile == "full":
        try:
            m = WhisperModel(full_name, device="cuda", compute_type="float16")
            print(f"[STT] {full_name} on CUDA/float16 (full power).")
            return m, "full"
        except Exception as _e:
            print(f"[STT] CUDA unavailable ({_e}) — using {lite_name} on CPU.")
    m = WhisperModel(lite_name, device="cpu", compute_type="int8")
    print(f"[STT] {lite_name} on CPU/int8 "
          f"({'low power' if profile == 'lite' else 'fallback'}).")
    return m, "lite"


def _load_stt_model(profile: str) -> None:
    """Build the requested model, swap it in atomically, free the old one."""
    global _STT, _STT_PROFILE
    try:
        model, actual = _build_stt(profile)
    except Exception as _e:
        _logger.error("Whisper load failed: %s", _e)
        return
    with _STT_LOCK:
        old = _STT
        _STT = model
        _STT_PROFILE = actual
        _STT_READY.set()
    if old is not None:
        del old
        import gc
        gc.collect()
    # Pre-warm so the first real utterance isn't slow
    try:
        with _STT_LOCK:
            list(_STT.transcribe(np.zeros(16000, dtype=np.float32),
                                 language="en")[0])
    except Exception:
        pass
    _logger.info("Whisper ready — profile=%s", _STT_PROFILE)


def _load_wake_stt() -> None:
    """Load the dedicated TINY wake-word model on CPU/int8 (v1.38). Runs on a
    background thread at boot; failure is non-fatal (wake falls back to _STT)."""
    global _WAKE_STT
    try:
        from faster_whisper import WhisperModel
        try:
            import wivexx_i18n as _i18n
            name = _i18n.whisper_wake_model()
        except Exception:
            name = "tiny.en"
        model = WhisperModel(name, device="cpu", compute_type="int8")
        with _WAKE_STT_LOCK:
            _WAKE_STT = model
            _WAKE_STT_READY.set()
        try:                               # pre-warm so the first wake is instant
            with _WAKE_STT_LOCK:
                list(_WAKE_STT.transcribe(np.zeros(16000, dtype=np.float32),
                                          language=None, beam_size=1)[0])
        except Exception:
            pass
        print(f"[STT] wake model '{name}' ready (fast wake detection).")
    except Exception as _e:
        _logger.warning("wake model load failed (%s) — wake uses the main model.", _e)


def _transcribe_wake(audio: np.ndarray) -> str:
    """Fast, greedy transcription for WAKE detection only — tiny MULTILINGUAL model,
    no VAD filter (the wake loop already gates on RMS + captures a bounded clip),
    beam=1. v1.57: language=None (auto-detect, was forced 'en') and NO wake-biased
    initial_prompt — so Spanish/other speech transcribes FAITHFULLY instead of being
    hallucinated INTO the wake word (which was the #1 false-wake cause). Falls back to
    the main model (also auto-detect, strict confidence gate) if the tiny model isn't
    ready."""
    if not _WAKE_STT_READY.is_set() or _WAKE_STT is None:
        return _transcribe(audio, "", strict=True)
    try:
        audio_16k = _resample_to_16k(audio.flatten().astype(np.float32), SAMPLE_RATE)
        f32 = audio_16k / 32768.0
        with _WAKE_STT_LOCK:
            segs, _info = _WAKE_STT.transcribe(
                f32, language=None, beam_size=1, best_of=1,
                condition_on_previous_text=False,
                no_speech_threshold=0.6)
            seg_list = list(segs)
        raw = " ".join(s.text for s in seg_list).strip()
        return _phonetic_normalize(raw) if raw else ""
    except Exception as _e:
        _logger.debug("wake transcribe failed (%s) — main model.", _e)
        return _transcribe(audio, "", strict=True)


def _set_power_mode(low: bool) -> None:
    """Flip between full-power and low-power operation (called by wivexx_perf).
    Low power: lite Whisper on CPU, memory consolidation paused."""
    global _LOW_POWER_MODE
    if low == _LOW_POWER_MODE:
        return
    _LOW_POWER_MODE = low
    threading.Thread(
        target=_load_stt_model, args=("lite" if low else "full",),
        daemon=True, name="wivexx-stt-switch",
    ).start()
    print(f"[PERF] Switching to {'LOW-POWER' if low else 'FULL-POWER'} mode.")
    try:
        if _wivexx_gui:
            _wivexx_gui.set_status("● Low-power mode" if low else "● Idle")
            if hasattr(_wivexx_gui, "set_power_mode"):
                _wivexx_gui.set_power_mode(low)
            _wivexx_gui.set_memory_status(_OLLAMA_AVAILABLE and not low)
    except Exception:
        pass


def _apply_autostart(enabled: bool) -> bool:
    """Create or remove the Windows silent-autostart launcher (Settings → Launch on
    startup). Callable at runtime AND at boot. Returns True on success."""
    try:
        startup_dir = os.path.expandvars(
            r"%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup")
        vbs_path = os.path.join(startup_dir, "wivexx_startup.vbs")
        if enabled:
            os.makedirs(startup_dir, exist_ok=True)
            pythonw_path = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
            if not os.path.exists(pythonw_path):
                pythonw_path = sys.executable
            script_path = os.path.abspath(__file__)
            vbs_content = (
                'Set WshShell = CreateObject("WScript.Shell")\n'
                f'WshShell.Run chr(34) & "{pythonw_path}" & chr(34) & " " '
                f'& chr(34) & "{script_path}" & chr(34), 0\n'
                'Set WshShell = Nothing\n'
            )
            existing = ""
            try:
                with open(vbs_path, encoding="utf-8") as f:
                    existing = f.read()
            except OSError:
                pass
            if existing != vbs_content:
                with open(vbs_path, "w", encoding="utf-8") as f:
                    f.write(vbs_content)
                _logger.info("Autostart enabled — startup script written.")
        elif os.path.exists(vbs_path):
            os.remove(vbs_path)
            _logger.info("Autostart disabled — startup script removed.")
        return True
    except Exception as e:
        _logger.warning("Autostart apply failed: %s", e)
        return False


def _apply_low_resource(on: bool) -> None:
    """Settings → Low-resource mode: force low-power (lite Whisper on CPU, memory
    consolidation paused) or return to auto/full."""
    try:
        _set_power_mode(bool(on))
    except Exception as e:
        _logger.warning("low-resource apply failed: %s", e)


print("Loading Whisper in the background — interface starts immediately...")
threading.Thread(target=_load_stt_model, args=("full",), daemon=True,
                 name="wivexx-stt-load").start()
# v1.38: load the tiny wake model in parallel so wake detection is fast the
# moment Wivexx boots (independent of the heavier command model).
threading.Thread(target=_load_wake_stt, daemon=True,
                 name="wivexx-wake-stt-load").start()


# ══════════════════════════════════════════════════════════════════════════════
#  INTERRUPTIBLE TTS
# ══════════════════════════════════════════════════════════════════════════════
_tts_stop_evt   = threading.Event()
_tts_interrupted_audio: list[np.ndarray] = []
# v1.58: speech captured while Wivexx was THINKING (a blocking _ask_claude call,
# which the barge-in monitor doesn't cover). Kept SEPARATE from the barge-in
# buffer because speak() clears that one — this survives so the follow-up is
# answered as the next turn instead of vanishing into the "deadzone".
_pending_followup_audio: list[np.ndarray] = []

_IN_REQUEST_MODE: bool = False  # Feature 2: dictation mode guard
_suppress_bargein: bool = False  # suppresses barge-in during request setup speech

# ── v1.7: remote (web) reply capture ──────────────────────────────────────────
# While a phone/web turn is being handled, speak() collects the reply text for
# the browser instead of playing TTS on the PC speakers.  Guarded by thread id
# so concurrent speak() calls from hotkeys/timers are never miscaptured.
_remote_capture: Optional[list] = None
_remote_capture_tid: Optional[int] = None
# True only while the CURRENT turn came from the live voice/mic session loop. Typed
# (GUI), remote (web), shortcut and announcement turns leave it False — so flows that
# would otherwise listen on the mic for a yes/no (e.g. the shutdown confirm) know to
# ask for an explicit TYPED confirmation instead of recording silence → auto-cancel.
_VOICE_TURN: bool = False

# v1.7.1: True while TTS is playing.  The wake-word listener ignores audio
# during playback so WIVEXX can't wake himself by saying his own name through
# the speakers (the greeting and standby announcements contain "WIVEXX").
_tts_active = False
# v1.31: serialise TTS so two replies (e.g. a command ack + a background worker's
# "done") never play over each other — speak() calls queue on this lock.
_speak_lock = threading.Lock()

# ── Status label text for GUI ─────────────────────────────────────────────────
_GUI_STATUS_LABELS: dict[str, str] = {
    "standby":    "● Idle",
    "idle":       "● Idle",
    "active":     "● Active",
    "listening":  "● Listening...",
    "searching":  "● Searching...",
    "processing": "● Thinking...",
    "speaking":   "● Speaking...",
    "mic_muted":  "● Mic muted",
    "audio_muted":"● Voice paused",
}


def _monitor_mic_while_speaking() -> None:
    """Monitor mic during TTS; barge-in only on voiced audio (Feature 1).

    300 ms guard window at start prevents self-trigger from TTS audio.
    Barge-in is additionally gated behind _is_voiced() to filter hums/clicks.
    """
    chunk_n      = int(SAMPLE_RATE * 0.05)           # 50 ms frames
    # Feature 1: use BARGEIN_GATE if set, else fall back to old threshold
    bargein_rms  = getattr(cfg, "BARGEIN_GATE", 0.025) * 32768
    trigger_gate = max(bargein_rms, cfg.SILENCE_THRESHOLD * 0.9, 120)
    bargein_consec = max(2, int(getattr(cfg, "BARGEIN_CONSEC_FRAMES", 6)))
    device       = _get_device()
    consec_loud  = 0
    triggered    = False
    silent_count = 0
    chunks: list[np.ndarray] = []
    pre_buf: list[np.ndarray] = []
    pre_size     = 10
    guard_frames = 6   # 300 ms @ 50 ms/frame — ignore first 300 ms (Feature 1)
    frame_count  = 0
    _dead_buf_count = 0

    try:
        with sd.InputStream(
            samplerate=SAMPLE_RATE, channels=1,
            dtype="int16", blocksize=chunk_n, device=device,
        ) as stream:
            while True:
                if not triggered and _tts_stop_evt.is_set():
                    return

                block, _ = stream.read(chunk_n)
                # Dead-buffer guard: WASAPI silently returns zeros on sample-rate mismatch
                if _dead_buf_count is not None and np.max(np.abs(block)) == 0.0:
                    _dead_buf_count += 1
                    if _dead_buf_count >= 20:   # ~1 s of silence at 50 ms chunks
                        print("[AUDIO FAULT] Windows returned a dead buffer (all zeros). "
                              "Check sample rate or Windows Sound Control Panel.")
                        _dead_buf_count = 0
                else:
                    _dead_buf_count = 0
                frame_count += 1
                rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))

                if not triggered:
                    pre_buf.append(block.copy())
                    if len(pre_buf) > pre_size:
                        pre_buf.pop(0)

                    if _mic_muted:
                        continue

                    # 300 ms guard: skip first frames to prevent self-trigger
                    if frame_count <= guard_frames:
                        continue

                    # Suppressed during request-setup speech (FIX 1)
                    if _suppress_bargein:
                        continue

                    # Call mode: the user is talking to OTHER PEOPLE — never
                    # interrupt TTS from open-mic audio (PTT still interrupts).
                    if _call_mode:
                        continue

                    # v1.11: barge-in only applies INSIDE a conversation.  The
                    # boot greeting and background announcements play through —
                    # talking near the mic no longer silences WIVEXX when no
                    # session is active (say the wake word or use PTT instead).
                    if not _in_active_session:
                        continue

                    # Feature 1 (hardened v1.6): gate barge-in behind RMS +
                    # human-voice check (VAD + speech-band spectrum) sustained
                    # for BARGEIN_CONSEC_FRAMES frames.
                    # v1.17 AEC: optionally subtract our own TTS from the frame,
                    # and raise the gate by the echo estimate so WIVEXX's own
                    # voice no longer self-triggers.  Wrapped — any failure falls
                    # straight back to the original behaviour.
                    blk = block.flatten()
                    eff_gate = trigger_gate
                    if _aec_mod is not None:
                        try:
                            if getattr(cfg, "AEC_ENABLED", False):
                                blk = _aec_mod.cancel(blk, SAMPLE_RATE)
                                rms = float(np.sqrt(np.mean(
                                    blk.astype(np.float32) ** 2)))
                            eff_gate = _aec_mod.adjusted_gate(trigger_gate)
                        except Exception:
                            blk = block.flatten()
                            eff_gate = trigger_gate
                    if rms > eff_gate and _is_human_voice(blk):
                        consec_loud += 1
                        if consec_loud >= bargein_consec:
                            triggered = True
                            _tts_stop_evt.set()             # signal audio playback thread immediately
                            try:
                                if _HAS_EDGE_TTS:
                                    pygame.mixer.music.stop()
                                if _pyttsx3_eng is not None:
                                    _pyttsx3_eng.stop()
                            except Exception:
                                pass
                            _tts_interrupted_audio.clear()  # flush any stale pre-interrupt audio
                            _logger.debug("Turn ended: reason=barge-in")
                            print("[INTERRUPT] Stopped — listening, sir.")
                            # v1.11: flip the GUI/tray to 'listening' THE MOMENT
                            # the interrupt lands — not after the capture ends
                            set_tray("listening", "Wivexx — listening")
                            chunks = list(pre_buf)
                    else:
                        consec_loud = max(0, consec_loud - 1)

                else:
                    chunks.append(block.copy())
                    if rms < cfg.SILENCE_THRESHOLD:
                        silent_count += 1
                        if silent_count >= int(SILENCE_HOLD_SECS / 0.05) and len(chunks) > 20:
                            break
                    else:
                        silent_count = 0

                    if len(chunks) > int(MAX_QUESTION_SECS / 0.05):
                        break

    except Exception as e:
        print(f"[Monitor] {e}")

    if triggered and chunks:
        _tts_interrupted_audio.clear()
        _tts_interrupted_audio.append(np.concatenate(chunks).flatten())
    _tts_stop_evt.set()


# ══════════════════════════════════════════════════════════════════════════════
#  TTS PLAYBACK ENGINE (v1.8) — sounddevice output with stall-proof fallback
#
#  Why: SDL/pygame (and even Windows MCI) hang forever when the default audio
#  endpoint stops consuming samples — e.g. an Arctis base station that went to
#  sleep.  Verified live on 2026-06-11: every utterance timed out and WIVEXX
#  went mute.  PortAudio (sounddevice) can target ANY output device, so each
#  utterance now: resolves the current Windows default → probes it with 0.2 s
#  of silence under a hard time budget → plays on it, or falls back to the
#  next responding output so WIVEXX is never silenced by one dead device.
# ══════════════════════════════════════════════════════════════════════════════
_sd_bad_outputs: dict[int, float] = {}        # sd device index → cooldown-until
_sd_good_output = {"idx": None, "until": 0.0}  # last verified-good device


def _decode_mp3(path: str):
    """Decode an MP3 to a float32 stereo array WITHOUT playing it (pygame's
    decoder works even when its playback device is stalled)."""
    import pygame.sndarray as _sndarray
    snd = pygame.mixer.Sound(path)
    arr = _sndarray.array(snd)
    if arr.ndim == 1:
        arr = np.column_stack([arr, arr])
    init = pygame.mixer.get_init() or (22050, -16, 2)
    return np.ascontiguousarray(arr.astype(np.float32) / 32768.0), int(init[0])


def _sd_output_candidates() -> list[int]:
    """Ordered output-device candidates: the current Windows default endpoint
    first (matched by name against PortAudio's WASAPI devices), then the
    PortAudio default, then every other output. Cooldown devices excluded."""
    cands: list[int] = []

    def _add(i) -> None:
        if i is not None and i >= 0 and i not in cands:
            cands.append(i)

    try:
        devs = sd.query_devices()
        try:
            wasapi_idx = next(i for i, h in enumerate(sd.query_hostapis())
                              if "wasapi" in h["name"].lower())
        except Exception:
            wasapi_idx = None

        # 1. current Windows default endpoint, by fuzzy name match
        default_name = ""
        try:
            from pycaw.pycaw import AudioUtilities
            import wivexx_audio_devices as _ad
            cur_id = str(getattr(AudioUtilities.GetSpeakers(), "id", "") or "")
            for d in _ad.list_output_devices():
                if d["id"] == cur_id:
                    default_name = d["name"].lower()
                    break
        except Exception:
            pass
        if default_name:
            best, best_r = None, 0.0
            for i, dv in enumerate(devs):
                if dv["max_output_channels"] <= 0:
                    continue
                if wasapi_idx is not None and dv["hostapi"] != wasapi_idx:
                    continue
                r = difflib.SequenceMatcher(None, default_name,
                                            dv["name"].lower()).ratio()
                if r > best_r:
                    best, best_r = i, r
            if best_r >= 0.5:
                _add(best)

        # 2. PortAudio's own default output
        try:
            _add(sd.default.device[1])
        except Exception:
            pass

        # 3. every other output device — physical-looking devices first, then
        #    WASAPI before legacy APIs.  Virtual sinks (Voicemeeter, Sonar,
        #    Steam Streaming, …) ALWAYS accept audio even when nobody hears
        #    them, so they must be the very last resort.
        _VIRTUAL_RE = re.compile(
            r"voicemeeter|vb-audio|sonar|steam\s+streaming|oculus|virtual",
            re.IGNORECASE)
        for pass_virtual in (False, True):
            for pass_wasapi in (True, False):
                for i, dv in enumerate(devs):
                    if dv["max_output_channels"] <= 0:
                        continue
                    if bool(_VIRTUAL_RE.search(dv["name"])) != pass_virtual:
                        continue
                    is_w = (wasapi_idx is not None and dv["hostapi"] == wasapi_idx)
                    if is_w == pass_wasapi:
                        _add(i)
    except Exception as e:
        _logger.debug("TTS: candidate enumeration failed: %s", e)

    now = time.time()
    return [c for c in cands if _sd_bad_outputs.get(c, 0.0) < now]


def _probe_sd_output(idx: int, rate: int) -> bool:
    """Write 0.2 s of silence to the device inside a disposable daemon thread
    with a hard wall-clock budget — a stalled endpoint blocks forever, so the
    join timeout is the detector."""
    result = {"ok": False}

    def _t() -> None:
        try:
            with sd.OutputStream(samplerate=rate, channels=2, dtype="float32",
                                 device=idx, blocksize=1024) as st:
                st.write(np.zeros((int(rate * 0.2), 2), dtype=np.float32))
            result["ok"] = True
        except Exception:
            pass

    th = threading.Thread(target=_t, daemon=True)
    th.start()
    th.join(timeout=2.5)
    return result["ok"] and not th.is_alive()


def _play_pcm(audio: np.ndarray, rate: int) -> bool:
    """Chunked playback honoring _tts_stop_evt (stop) and _audio_muted (pause).
    Tries each candidate device; stalled ones get a 2-minute cooldown."""
    # Settings (read live so a change applies to the next reply, no restart):
    #   mute_voice → don't speak (text still shows); tts_volume 0-100 → gain.
    try:
        import wivexx_settings as _ws
        if _ws.get("mute_voice", False):
            return True                       # muted → nothing to play, report success
        _vol = _ws.get("tts_volume", 100)
        _vol = 100 if _vol is None else max(0, min(100, int(_vol)))
        if _vol <= 0:
            return True
        if _vol != 100:
            audio = (audio * (_vol / 100.0)).astype(audio.dtype, copy=False)
    except Exception:
        pass
    now = time.time()
    candidates = _sd_output_candidates()
    if not candidates:
        _logger.error("TTS: no audio output devices available.")
        return False

    for nth, idx in enumerate(candidates):
        skip_probe = (idx == _sd_good_output["idx"]
                      and now < _sd_good_output["until"])
        if not skip_probe and not _probe_sd_output(idx, rate):
            _sd_bad_outputs[idx] = time.time() + 120
            _logger.warning("TTS: output device %d not responding — trying next.", idx)
            continue
        try:
            chunk = max(256, int(rate * 0.05))
            n = len(audio)
            with sd.OutputStream(samplerate=rate, channels=2, dtype="float32",
                                 device=idx, blocksize=chunk) as st:
                pos = 0
                while pos < n:
                    if _tts_stop_evt.is_set():
                        break
                    if _audio_muted:
                        time.sleep(0.05)
                        continue
                    end = min(pos + chunk, n)
                    st.write(audio[pos:end])
                    if _aec_mod is not None:       # v1.17: feed echo reference
                        try:
                            _aec_mod.push_output(audio[pos:end], rate)
                        except Exception:
                            pass
                    pos = end
                    if _wivexx_gui:
                        try:
                            _wivexx_gui.push_level(
                                0.2 + 0.15 * abs(math.sin(time.time() * 7.0)))
                        except Exception:
                            pass
            _sd_good_output["idx"] = idx
            _sd_good_output["until"] = time.time() + 60
            if nth > 0:
                _logger.warning("TTS: spoke through fallback device %d — the "
                                "default output is not responding.", idx)
            return True
        except Exception as e:
            _sd_bad_outputs[idx] = time.time() + 120
            _logger.warning("TTS: playback failed on device %d (%s) — trying next.",
                            idx, e)

    _logger.error("TTS: every audio output failed — is anything powered on?")
    try:
        if _wivexx_gui:
            _wivexx_gui.set_status("No audio output responding")
    except Exception:
        pass
    return False


async def _edge_speak(text: str) -> None:
    try:
        import wivexx_i18n as _i18n
        _voice = _i18n.tts_voice()
    except Exception:
        _voice = cfg.VOICE
    communicate = edge_tts.Communicate(text, _voice)
    with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f:
        tmp = f.name
    try:
        await communicate.save(tmp)
        audio, rate = _decode_mp3(tmp)
        loop = asyncio.get_running_loop()
        ok = await loop.run_in_executor(None, _play_pcm, audio, rate)
        if not ok:
            raise RuntimeError("no working audio output")
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass


def _reinit_tts_mixer() -> None:
    """Re-bind pygame's audio stream to the CURRENT default output device.

    SDL keeps a stream open on whatever device was default at init.  When the
    default changes (the new output-switch commands) or that device stalls
    (sleeping monitor / powered-off headset base), playback can hang forever
    — which once froze the whole boot on the greeting (v1.7.1 fix).  Called
    after every output switch and after a TTS timeout."""
    if not _HAS_EDGE_TTS:
        return
    try:
        pygame.mixer.music.stop()
    except Exception:
        pass
    try:
        pygame.mixer.quit()
        pygame.mixer.init()
        _logger.info("TTS mixer re-initialised on the current output device.")
    except Exception as e:
        _logger.warning("TTS mixer reinit failed: %s", e)


def _clean_for_tts(text: str) -> str:
    """Strip markdown formatting so TTS engines speak clean text."""
    # Remove bold/italic markers: **text** -> text, *text* -> text
    text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
    text = re.sub(r'\*(.+?)\*',     r'\1', text)
    # Remove inline code markers: `code` -> code
    text = re.sub(r'`(.+?)`', r'\1', text)
    # Remove markdown headers: ## Heading -> Heading
    text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
    # Remove horizontal rules
    text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
    # Collapse extra whitespace
    text = re.sub(r'\n{3,}', '\n\n', text)
    return text.strip()


# ── v1.37: never READ a raw error aloud ───────────────────────────────────────
# A caught exception occasionally leaks into a spoken line ("I couldn't save
# that app, sir. [Errno 13] Permission denied: '…'"). Reading a traceback /
# exception repr aloud is jarring and useless. This is the single TTS chokepoint,
# so any text carrying an unmistakable error signature is swapped for a calm,
# in-character apology here (the real error is still logged in full). Signatures
# are chosen to NEVER appear in normal butler prose (Traceback, CamelCase
# *Error/*Exception class names, [Errno N], object reprs, long hex, .py:line).
_ERROR_SIGNATURE_RE = re.compile(
    r"Traceback \(most recent call last\)"
    r"|\bTraceback\b"
    r"|\b[A-Z]\w*(?:Error|Exception|Warning)\b"          # KeyError, HTTPError, …
    r"|\[Errno\s+-?\d+\]"
    r"|<[\w.]+ (?:object|instance) at 0x[0-9a-fA-F]+>"
    r"|0x[0-9a-fA-F]{6,}"
    r'|File "[^"]+", line \d+'
    r"|\bline \d+, in \w"
)


def _looks_like_error(text: str) -> bool:
    """True when *text* carries an unmistakable raw-error signature."""
    return bool(text) and bool(_ERROR_SIGNATURE_RE.search(text))


def speak(text: str) -> None:
    global _last_tts_text, _tts_active, _wake_cooldown_until
    # v1.37: swap any leaked raw error for a calm apology (never read it aloud).
    if _looks_like_error(text):
        try:
            _logger.warning("speak(): suppressed raw error from TTS: %s", str(text)[:300])
        except Exception:
            pass
        text = "My apologies, sir — something went wrong with that. I've noted it in the log."
    # v1.34: the no-API setup guide is a WALL of steps — reading it aloud every
    # time was painful. Show the full guide in the chat, speak one short line.
    # (Checked BEFORE localization so the comparison always matches.)
    _no_api_wall = (text == NO_API_MSG)
    # Localize fixed English confirmations to the chosen language (conversational
    # replies from Claude already come back in-language and pass through unchanged).
    try:
        import wivexx_i18n as _i18n
        text = _i18n.say(text)
    except Exception:
        pass
    # v1.7: web/phone turns capture the reply instead of speaking it aloud
    if (_remote_capture is not None
            and threading.get_ident() == _remote_capture_tid):
        _remote_capture.append(text)
        _console_turn("WIVEXX", text)
        return
    if _no_api_wall:
        _console_turn("WIVEXX", text)      # the full guide lands in the chat
        text = ("I need an Anthropic API key for that, sir — the two-minute "
                "setup guide is in the chat.")
        try:
            import wivexx_i18n as _i18n
            text = _i18n.say(text)
        except Exception:
            pass
    _last_tts_text = text
    _speak_lock.acquire()                # queue behind any reply still being spoken
    _tts_active = True
    # Drive the "speaking" animation for EVERY spoken reply. Command confirmations
    # used to leave the dot on the idle/thinking state because individual handlers
    # forgot to call set_tray("speaking") (some even set "standby"/"processing"
    # right before speaking) — so the speaking animation only played for ordinary
    # questions. Setting it here, at the one TTS chokepoint, makes the speaking
    # animation play uniformly for commands and conversation alike. (dot.js dedups
    # repeated states, so handlers that already set it cost nothing.)
    try:
        if _current_state != "speaking":
            set_tray("speaking", "Wivexx — speaking")
    except Exception:
        pass
    # mascot reaction from the reply's tone — a quick expression so it feels alive:
    # 'suspicious' when something went wrong, 'happy' on a clear success/confirmation.
    try:
        if _wivexx_gui is not None:
            low = text.lower()
            if any(p in low for p in (
                    "couldn't", "could not", "can't", "cannot", "unable", "trouble",
                    "no connection", "didn't catch", "i didn't", "error", "failed",
                    "pardon", "not found", "went wrong",
                    # Spanish equivalents
                    "no pude", "no puedo", "no entend", "error", "falló", "problema")):
                _wivexx_gui.mascot_react("suspicious", 1.1)
            elif any(p in low for p in (
                    "done", "added", "set to", "opening", "got it", "complete",
                    "saved", "now playing", "on it", "here you go", "all set",
                    # Spanish equivalents
                    "hecho", "abriendo", "listo", "guardado", "enseguida", "reproduciendo")):
                _wivexx_gui.mascot_react("happy", 0.9)
    except Exception:
        pass
    monitor = None
    try:
        _console_turn("WIVEXX", text)
        _tts_stop_evt.clear()
        _tts_interrupted_audio.clear()

        # Strip markdown before sending to any TTS engine; console/GUI keep original
        _tts_text = _clean_for_tts(text)

        monitor = threading.Thread(target=_monitor_mic_while_speaking, daemon=True)
        monitor.start()

        tts_success = False
        if _HAS_EDGE_TTS:
            # v1.7.1: hard ceiling on every utterance.  A stalled output device
            # used to make playback spin forever, freezing the caller — at boot
            # that meant the GUI never opened.  Budget ≈ speech duration plus
            # generous headroom.
            _tts_budget = min(90.0, 10.0 + len(_tts_text) * 0.12)
            try:
                asyncio.run(asyncio.wait_for(_edge_speak(_tts_text), timeout=_tts_budget))
                tts_success = True
            except (asyncio.TimeoutError, TimeoutError):
                _tts_stop_evt.set()   # unstick the playback loop if it's wedged
                _logger.warning("TTS timed out after %.0fs — output device stalled?",
                                _tts_budget)
                tts_success = True   # don't double-speak through the fallback
            except Exception as _e:
                if "no working audio output" in str(_e):
                    tts_success = True   # SAPI would hang on the same dead devices
                elif "mixer" not in str(_e).lower():
                    print(f"[TTS error] {_e}")

        if not tts_success:                   # honor mute for the SAPI fallback too
            try:
                import wivexx_settings as _ws
                if _ws.get("mute_voice", False):
                    tts_success = True
            except Exception:
                pass
        if not tts_success:
            try:
                _eng = _get_pyttsx3()
            except Exception as _pe:
                _logger.warning("pyttsx3 fallback unavailable: %s", _pe)
                _eng = None
            if _eng is not None:
                for sentence in re.split(r'(?<=[.!?])\s+', _tts_text):
                    if _tts_stop_evt.is_set():
                        break
                    if _wivexx_gui:
                        try:
                            _wivexx_gui.push_level(0.25)
                        except Exception:
                            pass
                    _eng.say(sentence)
                    _eng.runAndWait()
    except Exception as _spk_e:
        _logger.warning("speak() failed: %s", _spk_e)
    finally:
        # v1.8: these MUST always run — a stuck _tts_active permanently
        # deafens the wake-word listener (looked like "WIVEXX stopped
        # responding"), and a missed monitor.join leaks a mic stream.
        _tts_stop_evt.set()
        _tts_active = False
        _wake_cooldown_until = max(_wake_cooldown_until, time.time() + 1.0)
        if monitor is not None:
            monitor.join(timeout=MAX_QUESTION_SECS + 3.0)
        try:
            _speak_lock.release()        # let the next queued reply start
        except RuntimeError:
            pass


def _console_turn(role: str, text: str, atts: Optional[list] = None) -> None:
    """Print a conversation turn with color coding and markdown bold rendering.
    v1.12: *atts* ([{name, kind, thumb}, …]) shows the actual images / file
    chips inside the GUI console and the phone chat feed."""
    # v1.34: multilingual turns are canonicalized to English for command
    # matching — but the chat feed must show what the user actually SAID.
    global _TURN_DISPLAY_TEXT
    if role == "You" and _TURN_DISPLAY_TEXT:
        text = _TURN_DISPLAY_TEXT
        _TURN_DISPLAY_TEXT = ""
    import datetime as _cdt
    ts = _cdt.datetime.now().strftime("%H:%M:%S")

    # ── ANSI bold for console: **text** → bold+text+reset ─────────────────────
    def _apply_console_bold(s: str) -> str:
        return re.sub(
            r'\*\*(.+?)\*\*',
            lambda m: _C["bold"] + m.group(1) + _C["reset"],
            s
        )

    # ── Strip markdown for GUI (no ANSI codes in tkinter) ─────────────────────
    def _strip_for_gui(s: str) -> str:
        s = re.sub(r'\*\*(.+?)\*\*', r'\1', s)  # bold
        s = re.sub(r'\*(.+?)\*',     r'\1', s)  # italic
        s = re.sub(r'`(.+?)`',       r'\1', s)  # inline code
        return s

    console_text = _apply_console_bold(text)
    if role == "You":
        line = f"\r{_C['amber']}[{ts}] You    {_C['reset']}{console_text}                    "
    else:
        line = f"\r{_C['cyan']}[{ts}] WIVEXX {_C['reset']}{console_text}                    "

    # Print to terminal
    try:
        print(line)
    except Exception:
        pass

    # Mirror to unified GUI console (plain text, no ANSI)
    if _wivexx_gui:
        try:
            _wivexx_gui.log_turn(role, _strip_for_gui(text), atts)
        except TypeError:
            _wivexx_gui.log_turn(role, _strip_for_gui(text))
        except Exception:
            pass

    # v1.8: mirror to the phone web chat feed (live visualization)
    try:
        _web_mod = sys.modules.get("wivexx_web")
        if _web_mod is not None:
            _web_mod.record_chat(role, _strip_for_gui(text), atts)
    except Exception:
        pass


def _pop_interrupt_audio() -> Optional[np.ndarray]:
    # barge-in (spoken over TTS) first — it's the most recent intent — then any
    # follow-up captured while Wivexx was thinking (v1.58). Pops are guarded:
    # producers clear()+append from other threads, so check-then-pop can race.
    try:
        return _tts_interrupted_audio.pop(0)
    except IndexError:
        pass
    try:
        return _pending_followup_audio.pop(0)
    except IndexError:
        pass
    return None


def _listen_during_thinking(stop_evt: threading.Event) -> None:
    """Capture speech spoken while Wivexx is THINKING (a blocking _ask_claude
    call). That window has no other mic monitoring, so anything said there used
    to be lost — the user spoke, was ignored, and the session went idle instead
    of answering. This records the window; if it contains a sustained voiced
    utterance, it's queued (_pending_followup_audio) to be answered as the NEXT
    turn. Runs on a daemon thread; stops the instant *stop_evt* is set (when the
    API returns, before speak() takes the mic). Fully fail-safe."""
    if (not _in_active_session or _call_mode or _mic_muted
            or _ptt_recording or _wake_enrolling):
        return
    try:
        from collections import deque
        chunk_n = int(SAMPLE_RATE * 0.05)
        gate = max(getattr(cfg, "BARGEIN_GATE", 0.03) * 32768,
                   cfg.SILENCE_THRESHOLD * 0.9, 120)
        need = max(2, int(getattr(cfg, "BARGEIN_CONSEC_FRAMES", 4)))
        device = _get_device()
        # v1.58 review fixes:
        #  - a ~0.8 s pre-roll ring until the FIRST voiced frame, then buffer —
        #    a silent thinking window no longer allocates the whole 45 s;
        #  - voicing must be CONSECUTIVE (like the barge-in monitor), so four
        #    scattered TV blips over a long window can't fake a follow-up;
        #  - any other mic owner appearing mid-window (TTS announcement, PTT,
        #    enrollment) ABORTS the capture — Wivexx must never record and then
        #    answer its own spoken announcements.
        pre = deque(maxlen=16)
        blocks: list = []
        consec = 0
        peak = 0
        max_frames = int(MAX_QUESTION_SECS / 0.05)
        with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16",
                            blocksize=chunk_n, device=device) as stream:
            while not stop_evt.is_set() and len(blocks) < max_frames:
                block, _ = stream.read(chunk_n)
                if _mic_muted or _call_mode or _tts_active or _ptt_recording \
                        or _wake_enrolling:
                    return
                rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
                if _wivexx_gui:
                    try:
                        _wivexx_gui.push_level(rms / 32768)
                    except Exception:
                        pass
                if rms > gate and _is_human_voice(block.flatten()):
                    consec += 1
                    peak = max(peak, consec)
                else:
                    consec = 0
                if blocks:                         # buffering already started
                    blocks.append(block)
                elif peak:                         # first voiced frame — prepend lead-in
                    blocks.extend(pre)
                    pre.clear()
                    blocks.append(block)
                else:                              # still waiting for speech
                    pre.append(block)
        # thinking finished — did the user say something SUSTAINED to Wivexx?
        if peak >= need and blocks:
            _pending_followup_audio.clear()
            _pending_followup_audio.append(np.concatenate(blocks).flatten())
            _logger.info("captured a follow-up spoken during processing "
                         "(%d consecutive voiced frames)", peak)
    except Exception as _e:
        _logger.debug("thinking-capture failed: %s", _e)


# ══════════════════════════════════════════════════════════════════════════════
#  SYSTEM TRAY
# ══════════════════════════════════════════════════════════════════════════════
_TRAY_COLORS = {              # Wivexx volt family (v1.18); states differ by shade
    "standby":    (138, 138, 133),   # stone
    "active":     (180, 235, 70),    # volt
    "listening":  (193, 247, 58),    # full volt
    "searching":  (193, 247, 58),    # volt
    "processing": (150, 170, 90),    # desaturated volt (thinking)
    "speaking":   (210, 250, 110),   # bright volt
    "mic_muted":  (224, 85,  58),    # danger
    "audio_muted":(224, 160, 48),    # amber
}

def _make_icon(state: str) -> Image.Image:
    """Tray icon (v1.18): the Wivexx DOT — a volt dot on an ink disc (the volt
    period from the `wivexx.` wordmark), tinted by state so listening / speaking
    / muted read at a glance.  Falls back to a plain disc if drawing fails."""
    col  = _TRAY_COLORS.get(state, (138, 138, 133))
    img  = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    draw.ellipse([2, 2, 62, 62], fill=(13, 13, 13, 255))           # ink disc
    draw.ellipse([10, 10, 54, 54], outline=col, width=2)           # thin halo ring
    draw.ellipse([21, 21, 43, 43], fill=col)                       # the DOT
    return img

_current_state = "standby"   # v1.8: mirrored to the web page's sphere
_last_state_ts = 0.0         # monotonic time of the last set_tray (watchdog)


def set_tray(state: str, tooltip: str = "Wivexx") -> None:
    global _current_state, _last_state_ts
    _current_state = state
    _last_state_ts = time.monotonic()
    if _tray_icon:
        _tray_icon.icon  = _make_icon(state)
        _tray_icon.title = tooltip
    # ── Unified GUI ───────────────────────────────────────────────────────────
    if _wivexx_gui:
        try:
            _wivexx_gui.set_gui_state(state)
            _wivexx_gui.set_status(_GUI_STATUS_LABELS.get(state, f"● {state}"))
        except Exception:
            pass
    # ── Live status line (Item 9) ─────────────────────────────────────────────
    if _IS_TTY:
        _STATUS_LABELS = {
            "standby":    (_C["dim"],   "● Idle"),
            "active":     (_C["green"], "● Active"),
            "listening":  (_C["cyan"],  "● Listening..."),
            "searching":  (_C["amber"], "● Searching..."),
            "processing": (_C["amber"], "● Thinking..."),
            "speaking":   (_C["cyan"],  "● Speaking..."),
            "mic_muted":  (_C["amber"], "● Mic muted"),
            "audio_muted":(_C["amber"], "● Voice paused"),
        }
        color, label = _STATUS_LABELS.get(state, (_C["dim"], f"● {state}"))
        print(f"\r{color}{label}{_C['reset']}          ", end="", flush=True)


# ── State watchdog (v1.22) ────────────────────────────────────────────────────
# The visual state ("speaking" / "thinking" / "idle") used to get stuck: a
# handler sets a transient state and relies on a trailing reset, but an early
# return, an exception, OR a GUI push that the window silently dropped (busy /
# not-ready) could strand the sphere on the wrong animation.  This watchdog is a
# safety net: whenever the assistant is quiescent (no TTS, no recording, no turn
# running) it computes the TRUE resting state and (a) corrects it if Python has
# it wrong, and (b) re-asserts it to the GUI so a dropped evaluate_js can't keep
# the dot stranded.  It never fights a legitimately busy state.
_TRANSIENT_STATES = {"processing", "searching", "speaking", "active"}

# Count of legitimate BACKGROUND jobs (running outside the turn lock) that own a
# "processing"/"searching" status — e.g. caption generation from the web panel.
# The watchdog won't reset those out from under them.
_bg_busy = 0
_bg_busy_lock = threading.Lock()


def _bg_busy_inc() -> None:
    global _bg_busy
    with _bg_busy_lock:
        _bg_busy += 1


def _bg_busy_dec() -> None:
    global _bg_busy
    with _bg_busy_lock:
        _bg_busy = max(0, _bg_busy - 1)


def _resting_state() -> str:
    """The state the sphere should settle to when nothing is happening."""
    if _mic_muted:
        return "mic_muted"
    if _audio_muted:
        return "audio_muted"
    if _call_mode:
        return "audio_muted"
    if _in_active_session:
        return "listening"
    return "standby"


def _resting_tooltip(state: str) -> str:
    return {
        "mic_muted":   "Wivexx — MIC MUTED  [Ctrl+Shift+M to unmute]",
        "audio_muted": "Wivexx — paused",
        "listening":   "Wivexx — listening",
        "standby":     "Wivexx — standby",
    }.get(state, "Wivexx")


def _repush_state() -> None:
    """Re-send the current sphere state to the GUI without touching the tray
    icon or the status text — heals a push the window dropped while it was busy
    (custom status lines like 'Call mode…' are preserved)."""
    if _wivexx_gui:
        try:
            _wivexx_gui.set_gui_state(_current_state)
        except Exception:
            pass


def _clipboard_react_loop() -> None:
    """When you COPY something, the mascot gives a quick curious 'alert' glance —
    the practical, privacy-safe stand-in for "react when I grab text": it watches
    the clipboard SEQUENCE NUMBER only (never reads the clipboard contents).
    Throttled, and only while idle/listening so it never clutters a reply."""
    try:
        import ctypes
        u = ctypes.windll.user32
    except Exception:
        return
    last = None
    last_react = 0.0
    while True:
        try:
            seq = int(u.GetClipboardSequenceNumber())
            now = time.time()
            if (last is not None and seq != last and now - last_react > 2.0
                    and _wivexx_gui is not None and not _mic_muted
                    and _current_state in ("idle", "standby", "listening")):
                last_react = now
                try:
                    _wivexx_gui.mascot_react("alert", 0.6)
                except Exception:
                    pass
            last = seq
        except Exception:
            pass
        time.sleep(0.5)


def _activity_detect_loop() -> None:
    """Context-aware mascot outfits 👕 — watches what you're DOING on the PC and
    dresses the orb to match: 🎧 music, 🎮 gaming, 🍿 video, 🤓 coding. Uses the
    foreground window (process + title) via wivexx_activity, plus the audio
    sessions (pycaw) so background music still earns headphones. Hysteresis keeps
    it from flickering when you alt-tab for a second."""
    try:
        import comtypes
        comtypes.CoInitialize()
    except Exception:
        pass
    try:
        import wivexx_activity as _act
    except Exception:
        return
    # audio sessions are optional — without pycaw we still do foreground detection
    try:
        from pycaw.pycaw import AudioUtilities, IAudioMeterInformation
        _have_audio = True
    except Exception:
        _have_audio = False

    def _audio_flags():
        """(any_audio_playing, a_music_app_is_playing, a_call_app_is_active). The call
        flag lets a Discord/Zoom call layer a headset on top of whatever app is in the
        foreground (e.g. a game). A call app counts as "in a call" when its audio
        session is ACTIVE (state 1) — not only when it's loud right now — because voice
        is bursty: someone listening through a silent gap is still on the call, so the
        headset shouldn't flicker off between words."""
        if not _have_audio:
            return (False, False, False)
        playing = music = call = False
        try:
            for s in AudioUtilities.GetAllSessions():
                try:
                    pname = ""
                    try:
                        pname = s.Process.name() if s.Process else ""
                    except Exception:
                        pass
                    is_call_app = _act.call_app_name(pname)
                    try:
                        peak = float(s._ctl.QueryInterface(IAudioMeterInformation).GetPeakValue())
                    except Exception:
                        peak = 0.0
                    if peak >= 0.02:
                        playing = True
                        if _act.music_app_name(pname):
                            music = True
                        if is_call_app:
                            call = True
                    # a call app with a live (Active) render session = you're in a call,
                    # even during the silence between people speaking
                    if is_call_app and not call:
                        try:
                            if int(s._ctl.GetState()) == 1:   # AudioSessionStateActive
                                call = True
                        except Exception:
                            pass
                except Exception:
                    pass
        except Exception:
            pass
        return (playing, music, call)

    cur = ""          # what the mascot is currently wearing
    cand = ""         # the most recent reading
    streak = 0        # how many consecutive reads agree with `cand`
    call_seen = 0.0   # hysteresis: last time call/music audio was heard, so a short
    music_seen = 0.0  # gap (a pause in the call, a track change) doesn't drop the
    CALL_GRACE = 10.0 # headset/headphones — bursty audio shouldn't make it flicker
    MUSIC_GRACE = 8.0
    while True:
        try:
            if _wivexx_gui is None:
                time.sleep(1.0)
                continue
            playing, music, call = _audio_flags()
            # v1.0: Wivexx's own CALL MODE always wears the headset + call-nod —
            # deterministic, not dependent on detecting voice-chat audio.
            call = call or _call_mode
            now = time.monotonic()
            if call:
                call_seen = now
            if music:
                music_seen = now
            call = call or (now - call_seen) <= CALL_GRACE
            music = music or (now - music_seen) <= MUSIC_GRACE
            acts = _act.detect_activities(playing, music, call)
            act = ",".join(acts)             # canonical multi-label string (may stack)
            if act == cand:
                streak += 1
            else:
                cand, streak = act, 1
            # switch once a reading holds for ~2 ticks (≈3s) — avoids alt-tab flicker
            if streak >= 2 and act != cur:
                cur = act
                global _CURRENT_ACTIVITY, _CURRENT_ACTIVITIES
                _CURRENT_ACTIVITY = acts[0] if acts else ""   # primary (perf guard)
                _CURRENT_ACTIVITIES = act                     # full set (wallpaper combos)
                try:
                    _wivexx_gui.set_activity(act)   # full set → outfits layer by slot
                except Exception:
                    pass
        except Exception:
            pass
        time.sleep(1.5)


def _wallpaper_folders():
    """Folders that hold the interactive desktop wallpaper, so we can drop a live
    state file next to its index.html (the WE web wallpaper loads it same-origin).
    Returns the editable master (beside this file) + the Wallpaper Engine copy."""
    out = []
    here = os.path.dirname(os.path.abspath(__file__))
    master = os.path.join(here, "desktop-wallpaper")
    if os.path.isdir(master):
        out.append(master)
    # Wallpaper Engine "myprojects" copy (best-effort across common Steam drives)
    rel = os.path.join("steamapps", "common", "wallpaper_engine",
                       "projects", "myprojects", "wivexx")
    for base in (r"C:\Program Files (x86)\Steam", r"C:\Program Files\Steam",
                 r"D:\SteamLibrary", r"E:\SteamLibrary", r"D:\Steam", r"E:\Steam"):
        p = os.path.join(base, rel)
        if os.path.isdir(p):
            out.append(p)
    return out


def _wallpaper_state_loop() -> None:
    """Write window.__wivexxState to wallpaper_state.js (~every 1.5 s) so the live
    desktop wallpaper can mirror the orb: current activity outfit, system load, and
    today's goals. Loaded via <script> injection, so it works under WE's file://
    sandbox. Best-effort and silent — the wallpaper degrades gracefully if absent."""
    import json as _json
    folders = _wallpaper_folders()
    if not folders:
        return
    # the user's first name for the greeting, if we know it (config, else blank)
    name = ""
    try:
        name = str(getattr(cfg, "USER_NAME", "") or getattr(cfg, "OWNER_NAME", "") or "").split(" ")[0]
    except Exception:
        name = ""
    while True:
        try:
            snap = {"cpu": None, "ram": None, "gpu": None}
            try:
                import wivexx_perf
                snap = wivexx_perf.snapshot()
            except Exception:
                pass
            goals = []
            try:
                import wivexx_goals
                goals = [g.get("title", "") for g in wivexx_goals.list_goals()[:3] if g.get("title")]
            except Exception:
                pass
            work_on = False
            try:
                import wivexx_workmode as _wm
                work_on = bool(_wm.is_enabled())
            except Exception:
                pass
            state = {
                "ts": round(time.time(), 1),
                "activity": _CURRENT_ACTIVITY or "",
                "activities": _CURRENT_ACTIVITIES or "",   # full combo set
                "work": work_on,                            # work mode → tie
                "dot": _current_state,
                "cpu": snap.get("cpu"), "ram": snap.get("ram"), "gpu": snap.get("gpu"),
                "goals": goals,
                "name": name,
            }
            payload = "window.__wivexxState = " + _json.dumps(state, ensure_ascii=False) + ";\n"
            for d in folders:
                try:
                    tmp = os.path.join(d, "wallpaper_state.js.tmp")
                    with open(tmp, "w", encoding="utf-8") as fh:
                        fh.write(payload)
                    os.replace(tmp, os.path.join(d, "wallpaper_state.js"))
                except Exception:
                    pass
        except Exception:
            pass
        time.sleep(1.5)


def _state_watchdog_loop() -> None:
    while True:
        try:
            time.sleep(1.0)
            # Busy in a way that legitimately owns a transient state? leave it.
            if _tts_active or _ptt_recording:
                continue
            # A turn in flight holds the turn lock; don't race its own resets.
            got = _TURN_LOCK.acquire(blocking=False)
            if not got:
                continue
            try:
                rest = _resting_state()
                age = time.monotonic() - _last_state_ts
                # Definitive stuck signals (nothing legitimately owns the state):
                #  • "speaking" with TTS finished → reset never ran
                #  • "active" lingering when we're not in a session
                #  • "processing"/"searching" with no background job holding it
                definitely_stuck = (
                    (_current_state == "speaking" and age > 1.2)
                    or (_current_state == "active" and rest != "active" and age > 1.2)
                    or (_current_state in ("processing", "searching")
                        and _bg_busy == 0 and age > 2.5)
                )
                if _current_state != rest and definitely_stuck:
                    set_tray(rest, _resting_tooltip(rest))
                else:
                    # state is right (or a benign mismatch) — re-assert the
                    # visual so a previously dropped push self-heals.
                    _repush_state()
            finally:
                _TURN_LOCK.release()
        except Exception:
            pass


def _toggle_mic_mute(icon=None, _=None) -> None:
    global _mic_muted, _last_mic_toggle
    now = time.time()
    if now - _last_mic_toggle < 0.5:
        return
    _last_mic_toggle = now

    _mic_muted = not _mic_muted
    if _mic_muted:
        set_tray("mic_muted", "Wivexx — MIC MUTED  [Ctrl+Shift+M to unmute]")
        print("[MIC] Muted — Wivexx will not hear you.")
        if _wivexx_gui is not None:
            try:
                _wivexx_gui.mascot_react("suspicious", 1.4)   # skeptical: can't hear you
            except Exception:
                pass
    else:
        set_tray("standby", "Wivexx — standby")
        print("[MIC] Unmuted.")
        speak("Microphone unmuted, sir.")


def _toggle_audio_mute(icon=None, _=None) -> None:
    global _audio_muted, _last_audio_toggle, _mic_muted, _mic_was_muted_before_pause
    now = time.time()
    if now - _last_audio_toggle < 0.5:
        return
    _last_audio_toggle = now

    _audio_muted = not _audio_muted
    if _audio_muted:
        _mic_was_muted_before_pause = _mic_muted
        _mic_muted = True
        try:
            if _HAS_EDGE_TTS:
                pygame.mixer.music.pause()
        except Exception:
            pass
        set_tray("audio_muted", "Wivexx — PAUSED  [Ctrl+Shift+V to resume]")
        print("[VOICE] Paused — mic also muted.")
    else:
        _mic_muted = _mic_was_muted_before_pause
        try:
            if _HAS_EDGE_TTS:
                pygame.mixer.music.unpause()
        except Exception:
            pass
        if _mic_muted:
            set_tray("mic_muted", "Wivexx — MIC MUTED")
        else:
            set_tray("standby", "Wivexx — standby")
        print(f"[VOICE] Resumed — mic {'still muted' if _mic_muted else 'unmuted'}.")


def _toggle_voice_mute(icon=None, _=None) -> None:
    """Deafen (v1.6): mute WIVEXX's voice output only — the mic stays live,
    unlike _toggle_audio_mute which pauses everything."""
    global _audio_muted
    _audio_muted = not _audio_muted
    try:
        if _HAS_EDGE_TTS:
            if _audio_muted:
                pygame.mixer.music.pause()
            else:
                pygame.mixer.music.unpause()
    except Exception:
        pass
    if _audio_muted:
        set_tray("audio_muted", "Wivexx — DEAFENED (voice muted, mic live)")
        print("[VOICE] Deafened — WIVEXX voice muted, mic still listening.")
    else:
        set_tray("mic_muted" if _mic_muted else "standby", "Wivexx — standby")
        print("[VOICE] Voice restored.")


def _toggle_call_mode(icon=None, _=None) -> None:
    """Call mode (v1.6): wake-word listening off, push-to-talk only.  Use it
    while on voice calls / playing with friends so WIVEXX never activates from
    open-mic conversation."""
    global _call_mode
    _call_mode = not _call_mode
    # v1.0: flip the mascot's call look IMMEDIATELY (headset + call-nod on both
    # faces) — the activity loop confirms it a couple of ticks later, but the
    # toggle should feel instant. Layered onto whatever else is being worn.
    try:
        if _wivexx_gui:
            _acts = [a for a in (_CURRENT_ACTIVITIES or "").split(",") if a]
            if _call_mode and "call" not in _acts:
                _acts.append("call")
            if not _call_mode and "call" in _acts:
                _acts.remove("call")
            _wivexx_gui.set_activity(",".join(_acts))
    except Exception:
        pass
    if _call_mode:
        set_tray("audio_muted", "Wivexx — CALL MODE (push-to-talk only)")
        print("[CALL MODE] ON — hold the push-to-talk key to speak to WIVEXX.")
        if _wivexx_gui:
            try:
                _wivexx_gui.set_status("● Call mode — hold PTT to talk")
            except Exception:
                pass
        speak("Call mode engaged, sir. Hold the push-to-talk key when you need me.")
    else:
        set_tray("standby", "Wivexx — standby")
        print("[CALL MODE] OFF — wake word listening restored.")
        speak("Call mode disengaged, sir. Listening for the wake word again.")


def _get_call_mode() -> bool:
    return _call_mode


def _toggle_gaming_mode(icon=None, _=None) -> None:
    """Performance mode (v1.30; formerly "gaming mode"): free the PC up — switch
    to the High-Performance power plan and SUSPEND a curated denylist of
    background bloat (browsers, Spotify, cloud sync, chat). Fully reversible;
    anything playing audio (a live call / music) is left running."""
    try:
        import wivexx_gaming_mode as _gm
    except Exception as e:
        _logger.warning("performance mode unavailable: %s", e)
        speak("I couldn't load performance mode, sir.")
        return
    try:
        now_on = _gm.toggle(announce=speak, logger=_logger)
        set_tray("standby",
                 "Wivexx — PERFORMANCE MODE" if now_on else "Wivexx — standby")
        try:                                  # pop the transparent in-game OSD with it
            if _wivexx_gui is not None and hasattr(_wivexx_gui, "toggle_perf_osd"):
                # v1.34: honor Settings → 'Performance overlay in optimized mode'
                _wivexx_gui.toggle_perf_osd(now_on and _osd_in_gaming_enabled())
        except Exception:
            pass
    except Exception as e:
        _logger.warning("performance mode toggle failed: %s", e)
        speak("Performance mode hit a snag, sir.")


def _get_gaming_mode() -> bool:
    try:
        import wivexx_gaming_mode as _gm
        return _gm.is_active()
    except Exception:
        return False


# ── Seamless auto-update: remember active modes across the update restart (v1.55)
# Before a hands-free update relaunch we snapshot which modes the user had ON, then
# silently re-activate them on the next boot so the restart is invisible. The file
# lives in the INSTALL dir — it survives the updater's robocopy (which never purges
# and /XF-excludes it) — and is one-shot (deleted the moment it's read).
_RESTORE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".wivexx_restore.json")


def _snapshot_active_modes() -> dict:
    m = {}
    try:
        m["call_mode"] = bool(_call_mode)
    except Exception:
        pass
    try:
        m["gaming_mode"] = bool(_get_gaming_mode())
    except Exception:
        pass
    try:
        import wivexx_workmode as _wm
        m["work_mode"] = bool(_wm.is_enabled())
    except Exception:
        pass
    return m


def _write_restore_state(reason: str = "update") -> None:
    """Persist the active-mode snapshot so the next boot can restore it."""
    try:
        with open(_RESTORE_PATH, "w", encoding="utf-8") as f:
            json.dump({"ts": time.time(), "reason": reason,
                       "modes": _snapshot_active_modes()}, f)
    except Exception as e:
        _logger.debug("restore-state write failed: %s", e)


def _update_is_safe_moment() -> bool:
    """True when restarting for an update won't interrupt the user: not mid-call or
    performance session, not speaking / recording, no heavy foreground activity."""
    try:
        if _call_mode or _get_gaming_mode():
            return False
        if globals().get("_tts_active") or globals().get("_ptt_recording"):
            return False
        act = str(globals().get("_CURRENT_ACTIVITIES") or "")
        if any(a in act for a in ("gaming", "video", "call", "meeting", "design")):
            return False
    except Exception:
        pass
    return True


def _restore_modes_if_pending() -> None:
    """Boot: silently re-activate the modes saved just before an auto-update
    relaunch so the update is unnoticeable. Only a FRESH snapshot (<10 min) counts;
    the file is one-shot. No spoken announcements — the whole point is invisibility."""
    global _call_mode
    try:
        if not os.path.exists(_RESTORE_PATH):
            return
        try:
            with open(_RESTORE_PATH, encoding="utf-8") as f:
                data = json.load(f)
        finally:
            try:
                os.remove(_RESTORE_PATH)
            except Exception:
                pass
        if (time.time() - float(data.get("ts", 0))) > 600:
            return
        modes = data.get("modes") or {}
        restored = []
        if modes.get("call_mode"):
            _call_mode = True
            try:
                set_tray("audio_muted", "Wivexx — CALL MODE (push-to-talk only)")
            except Exception:
                pass
            restored.append("call")
        if modes.get("gaming_mode"):
            try:
                import wivexx_gaming_mode as _gm
                if not _gm.is_active():
                    _gm.toggle(announce=(lambda *a, **k: None), logger=_logger)
                restored.append("performance")
            except Exception:
                pass
        if modes.get("work_mode"):
            try:
                import wivexx_workmode as _wm
                if not _wm.is_enabled():
                    _wm.set_enabled(True)
                restored.append("work")
            except Exception:
                pass
        if restored:
            _logger.info("auto-update: silently restored modes: %s", ", ".join(restored))
    except Exception as e:
        _logger.debug("restore modes failed: %s", e)


# ── Push-to-talk (v1.6; rewritten v1.6.2) ──────────────────────────────────────
# v1.6.2 deadlock fix: _ptt_busy used to be held through the ENTIRE turn
# (recording + transcription + Claude + TTS).  Pressing PTT again during that
# window dropped the capture — and left _ptt_down permanently set, so every
# later press bailed out at the key-repeat guard: PTT looked "dead" until
# restart.  Low-power mode made it worse because the Whisper model swap makes
# transcription hold things longer.  Now:
#   • _ptt_busy only covers the RECORDING phase (ends when the key is released)
#   • _ptt_down is ALWAYS cleared when a capture finishes or fails
#   • _ptt_press self-heals a stale flag by checking the physical key state
#   • transcription + turn handling run outside the lock (serialised by
#     _TURN_LOCK like every other turn)
def _ptt_press() -> None:
    """Fired by the hotkey engine the instant the PTT combo goes down.
    Must stay feather-light: cut TTS, set the flag, hand off to a thread."""
    # v1.8: PTT only acts in CALL MODE.  Outside it the wake word is already
    # listening, and a PTT key shared with normal usage (e.g. mouse4 as the
    # browser back-button) was hijacking the mic and interrupting WIVEXX.
    if not _call_mode:
        return
    if _ptt_down.is_set():
        # Stale-flag self-heal: if the combo is NOT physically held, a previous
        # capture died without cleanup — recover instead of staying dead.
        try:
            import wivexx_hotkeys as _hk
            if _hk.available() and not _hk.is_pressed("push_to_talk"):
                _ptt_down.clear()
                _logger.warning("PTT: cleared stale press flag (self-heal).")
            else:
                return              # genuinely still held — capture is running
        except Exception:
            return
    _ptt_down.set()
    _logger.debug("PTT: press")
    # Pressing PTT while WIVEXX is speaking always interrupts him
    _tts_stop_evt.set()
    try:
        if _HAS_EDGE_TTS:
            pygame.mixer.music.stop()
        if _pyttsx3_eng is not None:
            _pyttsx3_eng.stop()
    except Exception:
        pass
    threading.Thread(target=_ptt_capture, daemon=True, name="wivexx-ptt").start()


def _ptt_release() -> None:
    _ptt_down.clear()


def _ptt_record() -> Optional[np.ndarray]:
    """Recording phase only — returns raw audio or None. Caller holds nothing;
    this function owns _ptt_recording and always clears _ptt_down on exit."""
    global _ptt_recording
    chunk_n  = int(SAMPLE_RATE * 0.05)
    max_secs = float(getattr(cfg, "PTT_MAX_AUDIO_SECS", 45))
    chunks: list[np.ndarray] = []
    try:
        import wivexx_hotkeys as _hk
    except Exception:
        _hk = None
    try:
        _ptt_recording = True
        set_tray("listening", "Wivexx — push-to-talk")
        with sd.InputStream(samplerate=SAMPLE_RATE, channels=1,
                            dtype="int16", blocksize=chunk_n,
                            device=_get_device()) as stream:
            while len(chunks) < int(max_secs / 0.05):
                block, _ = stream.read(chunk_n)
                chunks.append(block.copy())
                if _wivexx_gui:
                    try:
                        rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
                        _wivexx_gui.push_level(rms / 32768)
                    except Exception:
                        pass
                # Stop when the event clears OR the keys are physically up
                # (poll guards against a missed key-up event)
                if not _ptt_down.is_set():
                    break
                if _hk is not None and _hk.available() and not _hk.is_pressed("push_to_talk"):
                    break
            # short tail so the last word isn't clipped
            for _ in range(4):
                block, _ = stream.read(chunk_n)
                chunks.append(block.copy())
    except Exception as e:
        _logger.warning("PTT capture failed: %s", e)
        return None
    finally:
        _ptt_recording = False
        _ptt_down.clear()           # NEVER leave the press flag stuck
    return np.concatenate(chunks).flatten() if chunks else None


def _ptt_capture() -> None:
    """Record while the PTT key is held, then transcribe and handle the turn.
    Runs on its own thread; _ptt_recording silences the wake-word listener so
    audio is never captured twice."""
    # Wait briefly if another RECORDING is still in flight (it ends as soon as
    # the previous key-hold ends, so this is at most a moment).
    if not _ptt_busy.acquire(timeout=5.0):
        _ptt_down.clear()
        _logger.warning("PTT: capture skipped — previous recording still active.")
        return
    try:
        audio = _ptt_record()
    finally:
        _ptt_busy.release()         # lock covers ONLY the recording phase

    min_secs = float(getattr(cfg, "PTT_MIN_AUDIO_SECS", 0.4))
    if audio is None or len(audio) / SAMPLE_RATE < min_secs:
        set_tray("standby", "Wivexx — standby")
        return                      # failed or accidental tap
    set_tray("processing", "Wivexx — processing")
    question = _transcribe(audio).strip()
    _logger.debug("PTT: transcript=%r", question)
    if not question:
        set_tray("standby", "Wivexx — standby")
        return
    if question.lower().startswith(cfg.WAKE_WORD.lower()):
        question = question[len(cfg.WAKE_WORD):].lstrip(" ,.")
    if question:
        _handle_turn(question)
    set_tray("standby", "Wivexx — standby")


# ── Conversation log helpers ───────────────────────────────────────────────────
def _log_turn(role: str, text: str) -> None:
    """Append a turn to the in-memory conversation log (for export)."""
    _conversation_log.append((time.time(), role, text))


def _save_conversation() -> str:
    """
    Dump conversation to a timestamped .txt in the conversations/ subfolder.
    Filename format: conversation_YYYY-MM-DD_HH-MM.txt
    Each line: [HH:MM:SS] Speaker: text
    """
    now = _dt.datetime.now()

    base_dir = os.path.dirname(os.path.abspath(__file__))
    conv_dir = os.path.join(base_dir, "conversations")
    os.makedirs(conv_dir, exist_ok=True)

    fname    = f"conversation_{now.strftime('%Y-%m-%d_%H-%M')}.txt"
    out_path = os.path.join(conv_dir, fname)

    # Session duration
    elapsed     = now.timestamp() - _session_start
    h, rem      = divmod(int(elapsed), 3600)
    m, s        = divmod(rem, 60)
    dur_str     = f"{h:02d}:{m:02d}:{s:02d}"
    turns       = len(_conversation_log)

    lines = [
        "Wivexx Conversation Export\n",
        f"Date/Time : {now.strftime('%Y-%m-%d %H:%M:%S')}\n",
        f"Duration  : {dur_str}\n",
        f"Turns     : {turns}\n",
        "=" * 60 + "\n\n",
    ]

    for ts, role, text in _conversation_log:
        label  = "You" if role == "user" else "WIVEXX"
        ts_str = _dt.datetime.fromtimestamp(ts).strftime("%H:%M:%S")
        lines.append(f"[{ts_str}] {label}: {text}\n\n")

    with open(out_path, "w", encoding="utf-8") as f:
        f.writelines(lines)

    _logger.info(f"Conversation exported → conversations/{fname}")
    return out_path


def _migrate_old_conversations() -> None:
    """
    Move any conversation_*.txt files from the project root into conversations/.
    Renames old YYYYMMDD_HHMMSS format to YYYY-MM-DD_HH-MM on the way.
    Called once at startup.
    """
    base_dir = os.path.dirname(os.path.abspath(__file__))
    conv_dir = os.path.join(base_dir, "conversations")

    pattern  = os.path.join(base_dir, "conversation_*.txt")
    old_files = _glob.glob(pattern)
    if not old_files:
        return

    os.makedirs(conv_dir, exist_ok=True)
    moved = 0
    for src in old_files:
        fname = os.path.basename(src)
        # Convert old format: conversation_20260529_231857.txt → conversation_2026-05-29_23-18.txt
        m = re.match(
            r"conversation_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.txt$",
            fname,
        )
        if m:
            yr, mo, dy, hh, mn, _ = m.groups()
            new_fname = f"conversation_{yr}-{mo}-{dy}_{hh}-{mn}.txt"
        else:
            new_fname = fname

        dst = os.path.join(conv_dir, new_fname)
        if os.path.exists(dst):
            base, ext = os.path.splitext(new_fname)
            dst = os.path.join(conv_dir, f"{base}_migrated{ext}")

        try:
            shutil.move(src, dst)
            moved += 1
            _logger.info(f"Migrated conversation: {fname} → conversations/{os.path.basename(dst)}")
        except Exception as e:
            _logger.warning(f"Could not migrate {fname}: {e}")

    if moved:
        print(f"[Migration] Moved {moved} old conversation file(s) -> conversations/")


def _run_tray() -> None:
    global _tray_icon
    if not _HAS_TRAY:
        return

    def _quit(icon, _):
        icon.stop()
        if _wivexx_gui:
            try:
                _wivexx_gui._force_close()
            except Exception:
                pass
        os.kill(os.getpid(), 9)

    def _show_gui(icon=None, _=None):
        if _wivexx_gui:
            try:
                _wivexx_gui.show_main()
            except Exception:
                pass

    def _clear_memory(icon, _):
        global _conversation
        _conversation = []
        speak("Conversation memory cleared, sir.")

    def _recalibrate(icon, _):
        try:
            if os.path.exists(CALIB_FILE):
                os.remove(CALIB_FILE)
            speak("Mic calibration cleared, sir. Restart Wivexx to re-calibrate.")
        except Exception:
            pass

    def _export_conversation(icon, _):
        if not _conversation_log:
            speak("There is no conversation to save, sir.")
            return
        try:
            path = _save_conversation()
            speak("Conversation saved, sir.")
            print(f"[Export] Saved -> {path}")
        except Exception as e:
            speak("I was unable to save the conversation, sir.")
            _logger.error(f"Export failed: {e}")

    def _mic_label(item):
        return "Unmute Mic  [Ctrl+Shift+M]" if _mic_muted else "Mute Mic  [Ctrl+Shift+M]"

    def _audio_label(item):
        return "Unmute Voice  [Ctrl+Shift+V]" if _audio_muted else "Mute Voice  [Ctrl+Shift+V]"

    def _taskbar_label(item):
        try:
            import wivexx_gui
            return "Show in taskbar" if wivexx_gui.is_taskbar_hidden() else "Hide from taskbar"
        except Exception:
            return "Hide from taskbar"

    def _toggle_taskbar(icon=None, item=None):
        try:
            import wivexx_gui
            wivexx_gui.set_taskbar_hidden(not wivexx_gui.is_taskbar_hidden())
        except Exception as _tb_e:
            _logger.warning("taskbar toggle failed: %s", _tb_e)

    menu = pystray.Menu(
        pystray.MenuItem("Wivexx  |  Claude Voice Assistant", None, enabled=False),
        pystray.Menu.SEPARATOR,
        pystray.MenuItem("Show Wivexx",          _show_gui, default=True),
        pystray.MenuItem(_taskbar_label, _toggle_taskbar),
        pystray.Menu.SEPARATOR,
        pystray.MenuItem(_mic_label,   _toggle_mic_mute),
        pystray.MenuItem(_audio_label, _toggle_audio_mute),
        pystray.Menu.SEPARATOR,
        pystray.MenuItem("Clear conversation memory", _clear_memory),
        pystray.MenuItem("Save conversation",          _export_conversation),
        pystray.MenuItem("Re-calibrate microphone",    _recalibrate),
        pystray.MenuItem("Close Wivexx",         _quit),
    )
    _tray_icon = pystray.Icon("Wivexx", _make_icon("standby"), "Wivexx — standby", menu)
    _tray_icon.run()


# ══════════════════════════════════════════════════════════════════════════════
#  AUDIO
# ══════════════════════════════════════════════════════════════════════════════

# Persistent input stream (used to warm up mic after TTS playback)
_stream: Optional[sd.InputStream] = None


def _ensure_stream_open() -> None:
    """Open (or verify) the global InputStream so mic is ready before recording.

    Called after speak() returns in the request-dictation loop to prevent
    audio dropout on the first frame of the user's next utterance.
    """
    global _stream
    RATE  = SAMPLE_RATE
    CHUNK = int(SAMPLE_RATE * 0.05)  # 50 ms frames
    try:
        if _stream is None or not _stream.active:
            _stream = sd.InputStream(
                samplerate=RATE, channels=1, dtype="int16",
                blocksize=CHUNK, device=_get_device(),
            )
            _stream.start()
            time.sleep(0.15)   # brief warmup so ADC settles
    except Exception as e:
        _logger.warning("Stream reopen: %s", e)


def _get_device() -> int | None:
    """Resolve cfg.MIC_DEVICE to a sounddevice input-device index.

    The chosen mic can come from (in priority order):
      - the runtime override set in the control room's Settings → Microphone
        (wivexx_settings.json "mic_device"), or
      - cfg.MIC_DEVICE

    and each can be:
      - None   → use system default input
      - int    → use that device index directly
      - str    → search device names for a case-insensitive substring match
    """
    spec = None
    try:                                  # runtime UI override wins (no restart)
        import wivexx_settings as _ws
        spec = _ws.get("mic_device", None)
    except Exception:
        spec = None
    if spec is None or spec == "" or spec == "default":
        spec = getattr(cfg, "MIC_DEVICE", None)
    if spec == "" or spec == "default":
        spec = None

    # None → system default
    if spec is None:
        idx = sd.default.device[0]
        if idx is not None and idx >= 0:
            d = sd.query_devices(int(idx))
            print(f"[Mic Lock] Using default device: {d['name']}")
        return idx

    # Integer index → use directly
    if isinstance(spec, int):
        try:
            d = sd.query_devices(spec)
            print(f"[Mic Lock] Using device [{spec}]: {d['name']}")
            return spec
        except Exception as _e:
            _logger.warning("_get_device: device index %d not found (%s) — using default.", spec, _e)
            return sd.default.device[0]

    # String → substring search (case-insensitive)
    if isinstance(spec, str):
        needle = spec.lower()
        for i, dev in enumerate(sd.query_devices()):
            if dev["max_input_channels"] > 0 and needle in dev["name"].lower():
                print(f"[Mic Lock] Matched '{spec}' → [{i}] {dev['name']}")
                return i
        _logger.warning("_get_device: no input device matching '%s' found — using default.", spec)
        fallback = sd.default.device[0]
        try:
            d = sd.query_devices(int(fallback))
            print(f"[Mic Lock] Fallback to default: {d['name']}")
        except Exception:
            pass
        return fallback

    # Unexpected type → default
    _logger.warning("_get_device: unexpected MIC_DEVICE type %s — using default.", type(spec))
    return sd.default.device[0]


def _resample_to_16k(audio_data: np.ndarray, orig_rate: int) -> np.ndarray:
    """FFT-based anti-aliased downsampler — any hardware rate → exactly 16 000 Hz.

    Uses scipy.signal.resample (Fourier method) instead of np.interp to prevent
    the stair-step aliasing that causes Silero VAD to classify speech as noise.
    """
    import scipy.signal
    flat       = audio_data.flatten().astype(np.float32)
    if orig_rate == 16000:
        return flat
    target_len = max(1, int(len(flat) * 16000 / orig_rate))
    resampled  = scipy.signal.resample(flat, target_len)
    return resampled.astype(np.float32)


def _fast_resample_for_vad(audio: np.ndarray, orig_rate: int) -> np.ndarray:
    """Fast linear interpolation for tiny VAD chunks (50 ms frames).
    Intentionally uses np.interp — immune to FFT boundary ringing (Gibbs phenomenon)
    that scipy.signal.resample introduces on short buffers, causing false barge-ins.
    _resample_to_16k (scipy FFT) is kept for full-utterance Whisper transcription."""
    if orig_rate == 16000:
        return audio.flatten().astype(np.float32)
    duration   = len(audio) / orig_rate
    target_len = max(1, int(duration * 16000))
    return np.interp(
        np.linspace(0.0, 1.0, target_len),
        np.linspace(0.0, 1.0, len(audio)),
        audio.flatten().astype(np.float32),
    ).astype(np.float32)


# ── Feature 1: VAD helper ─────────────────────────────────────────────────────
def _is_voiced(chunk: np.ndarray, rate: int = SAMPLE_RATE) -> bool:
    """Return True if the chunk contains human speech.
    Always resamples to 16 000 Hz so webrtcvad never sees 44100/48000 Hz."""
    try:
        import webrtcvad
        vad       = webrtcvad.Vad(3)                              # most aggressive
        pcm_16k   = _fast_resample_for_vad(chunk.flatten().astype(np.float32), rate)
        frame_len = 320                                           # 20 ms @ 16 kHz
        pcm_int16 = np.clip(pcm_16k * 32768, -32768, 32767).astype(np.int16)
        for start in range(0, len(pcm_int16) - frame_len + 1, frame_len):
            frame = pcm_int16[start : start + frame_len]
            if len(frame) == frame_len:
                try:
                    if vad.is_speech(frame.tobytes(), 16000):
                        return True
                except Exception:
                    continue
        return False
    except ImportError:
        # webrtcvad unavailable — strict energy + variance guard
        pcm_float = chunk.flatten().astype(np.float32) / 32768.0
        energy    = np.mean(pcm_float ** 2)
        variance  = np.var(pcm_float)
        return energy > 1e-4 and variance > 5e-5


def _speech_band_ratio(chunk: np.ndarray, rate: int = SAMPLE_RATE) -> float:
    """Share of spectral energy inside the human speech band (200–3500 Hz).
    Speech concentrates its energy there; music, game audio, keyboard clicks
    and door slams spread theirs across the full spectrum."""
    try:
        x = chunk.flatten().astype(np.float32)
        if x.size < 64:
            return 0.0
        x = x - float(np.mean(x))
        spec  = np.abs(np.fft.rfft(x * np.hanning(x.size))) ** 2
        freqs = np.fft.rfftfreq(x.size, d=1.0 / rate)
        total = float(np.sum(spec[freqs > 80.0]))   # ignore DC / rumble
        if total <= 0.0:
            return 0.0
        band = float(np.sum(spec[(freqs >= 200.0) & (freqs <= 3500.0)]))
        return band / total
    except Exception:
        return 1.0   # math failure must not permanently block barge-in


def _is_human_voice(chunk: np.ndarray, rate: int = SAMPLE_RATE) -> bool:
    """Stricter detector used for barge-in (v1.6): webrtcvad must agree AND
    most of the energy must sit in the speech band, so non-voice sounds can
    no longer cut WIVEXX off mid-sentence."""
    if not _is_voiced(chunk, rate):
        return False
    min_ratio = getattr(cfg, "BARGEIN_SPEECH_BAND_RATIO", 0.5)
    return _speech_band_ratio(chunk, rate) >= min_ratio


def _record_until_silence() -> np.ndarray:
    chunk_s    = 0.1
    chunk_n    = int(SAMPLE_RATE * chunk_s)
    max_silent = int(SILENCE_HOLD_SECS / chunk_s)
    max_total  = int(MAX_QUESTION_SECS  / chunk_s)
    chunks: list[np.ndarray] = []
    silent = 0
    device = _get_device()

    with sd.InputStream(
        samplerate=SAMPLE_RATE, channels=1,
        dtype="int16", blocksize=chunk_n, device=device,
    ) as stream:
        while len(chunks) < max_total:
            if _mic_muted or _ptt_recording:
                time.sleep(0.1)   # don't spin re-opening streams while gated
                return np.zeros(chunk_n * 5, dtype=np.int16)
            block, _ = stream.read(chunk_n)
            chunks.append(block.copy())
            rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
            if _wivexx_gui:
                try:
                    _wivexx_gui.push_level(rms / 32768)
                except Exception:
                    pass
            if rms < cfg.SILENCE_THRESHOLD:
                silent += 1
                if silent >= max_silent and len(chunks) > 20:
                    break
            else:
                silent = 0

    return np.concatenate(chunks).flatten()


def _record_triggered(max_wait: float = 20.0,
                      post_secs: float | None = None,
                      max_secs: float | None = None) -> Optional[np.ndarray]:
    """Record audio with adaptive VAD endpointing (Feature 1).

    Waits up to *max_wait* seconds for the first voiced frame, then records
    until *ENDPOINT_SILENCE_MS* of non-voiced audio after at least 0.5 s of
    voiced audio has been captured.
    """
    if post_secs is None:
        post_secs = getattr(cfg, "RECORD_POST_SILENCE_SECS", 3.0)
    if max_secs is None:
        max_secs = MAX_QUESTION_SECS

    chunk_n           = int(SAMPLE_RATE * 0.05)   # 50 ms frames
    gate              = max(cfg.SILENCE_THRESHOLD * 0.5, 80)
    device            = _get_device()
    endpoint_ms       = getattr(cfg, "ENDPOINT_SILENCE_MS", 900)
    min_voiced_frames = getattr(cfg, "VAD_MIN_VOICED_FRAMES", 6)
    endpoint_frames   = max(1, int(endpoint_ms / 50))   # 50 ms per frame
    # v1.58: was 0.5 s — a short imperative ("stop") only has ~0.15-0.2 s of
    # VOICED audio (the s/t are unvoiced), so the silence endpoint could never
    # fire and the recorder ran to MAX_QUESTION_SECS (45 s!) — the "it freezes
    # on listening and ignores me" bug. Any real word has ≥0.15 s of voicing.
    min_voiced_f      = int(0.15 / 0.05)                # 0.15 s minimum voiced
    _dead_buf_count = 0

    with sd.InputStream(
        samplerate=SAMPLE_RATE, channels=1,
        dtype="int16", blocksize=chunk_n, device=device,
    ) as stream:
        # ── Phase 1: wait for first loud frame, then start immediately ────────
        # Recording starts on the FIRST frame that exceeds the RMS gate —
        # no "wait for N voiced frames" guard (that guard was v1.3 regression
        # that silently discarded the first 300-400 ms of every utterance).
        pre_buf: list[np.ndarray] = []
        # v1.58: 300 ms → 800 ms pre-roll. The trigger needs a LOUD VOICED frame;
        # a soft first word ("please …") doesn't trip it, so by the time a louder
        # word did, the opening word had already scrolled out of a 300 ms buffer —
        # the "it cuts the first words of my sentence" bug.
        pre_size = 16   # 800 ms pre-roll at 50 ms/frame
        for _ in range(int(max_wait / 0.05)):
            block, _ = stream.read(chunk_n)
            rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
            if _wivexx_gui:
                try:
                    _wivexx_gui.push_level(rms / 32768)
                except Exception:
                    pass
            pre_buf.append(block.copy())
            if len(pre_buf) > pre_size:
                pre_buf.pop(0)
            # v1.58 review fix: never TRIGGER on Wivexx's own voice — a
            # background announcement (timer done, work mode) speak()ing from
            # another thread would otherwise start a capture of the assistant's
            # own speech, which then gets transcribed and answered as a turn.
            if _tts_active:
                pre_buf.clear()
                continue
            # v1.11: the trigger requires a VOICED frame, not just a loud one —
            # keyboard clatter / music can no longer start a recording
            if rms > gate and _is_voiced(block.flatten()):
                chunks = list(pre_buf)   # include pre-roll so first syllable isn't lost
                break
        else:
            return None  # timed out waiting for speech

        # ── Phase 2: record until adaptive endpoint ───────────────────────────
        voiced_frames  = 0
        silent_frames  = 0
        max_total_fr   = int(max_secs / 0.05)

        while len(chunks) < max_total_fr:
            b, _ = stream.read(chunk_n)
            # Dead-buffer guard: WASAPI silently returns zeros on sample-rate mismatch
            if _dead_buf_count is not None and np.max(np.abs(b)) == 0.0:
                _dead_buf_count += 1
                if _dead_buf_count >= 20:   # ~1 s of silence at 50 ms chunks
                    print("[AUDIO FAULT] Windows returned a dead buffer (all zeros). "
                          "Check sample rate or Windows Sound Control Panel.")
                    _dead_buf_count = 0
            else:
                _dead_buf_count = 0
            chunks.append(b.copy())
            rms = float(np.sqrt(np.mean(b.astype(np.float32) ** 2)))
            if _wivexx_gui:
                try:
                    _wivexx_gui.push_level(rms / 32768)
                except Exception:
                    pass
            is_v = rms > gate and _is_voiced(b.flatten())
            if is_v:
                voiced_frames += 1
                silent_frames  = 0
            else:
                silent_frames += 1
                if (silent_frames >= endpoint_frames
                        and voiced_frames >= min_voiced_f):
                    _logger.debug("Turn ended: reason=voiced-silence")
                    break
                # v1.58: HARD endpoint — whatever the voiced count, twice the
                # endpoint window of continuous silence ends the take. Without
                # this, a whisper-quiet turn (or a spurious trigger) recorded
                # dead air for the full 45 s max — the frozen "listening…" state.
                if silent_frames >= endpoint_frames * 2:
                    _logger.debug("Turn ended: reason=hard-silence "
                                  "(voiced_frames=%d)", voiced_frames)
                    break

        if len(chunks) >= max_total_fr:
            _logger.debug("Turn ended: reason=max-length")

        _utt = np.concatenate(chunks).flatten()
        global _last_utterance                    # v1.17: tap for voice biometrics
        _last_utterance = (_utt, SAMPLE_RATE, time.time())
        return _utt


def _record_yes_no(max_wait: float | None = None) -> str:
    if max_wait is None:
        max_wait = getattr(cfg, "YESNO_MAX_WAIT", 8.0)  # Item 7
    """
    Listen for a brief yes/no response.  Returns lowercase Whisper transcript.
    - Stops after 3 s of silence once speech has been detected.
    - Gives up after 5 s of total silence with no speech (treats as "no").
    - Hard cap of max_wait seconds overall.
    """
    chunk_s          = 0.1
    chunk_n          = int(SAMPLE_RATE * chunk_s)
    max_total        = int(max_wait  / chunk_s)
    silence_after    = int(3.0       / chunk_s)   # 3 s silence post-speech → stop
    no_speech_limit  = int(5.0       / chunk_s)   # 5 s total silence → no response
    chunks: list[np.ndarray] = []
    silent = 0
    loud   = 0
    device = _get_device()

    try:
        with sd.InputStream(
            samplerate=SAMPLE_RATE, channels=1,
            dtype="int16", blocksize=chunk_n, device=device,
        ) as stream:
            for _ in range(max_total):
                if _mic_muted:
                    break
                block, _ = stream.read(chunk_n)
                chunks.append(block.copy())
                rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
                if rms >= cfg.SILENCE_THRESHOLD:
                    silent = 0
                    loud  += 1
                else:
                    silent += 1
                    if loud > 3 and silent >= silence_after:
                        break          # heard speech, then silence → done
                    if loud == 0 and silent >= no_speech_limit:
                        break          # no speech at all → treat as "no"
    except Exception as e:
        _logger.debug(f"_record_yes_no: {e}")

    if not chunks:
        return ""
    audio = np.concatenate(chunks).flatten()
    return _transcribe(audio).strip().lower()


# Whisper's classic hallucinations on noise/silence — never valid commands.
# (Whisper was trained on YouTube-style data; on non-speech audio it emits
# outro phrases. Every production pipeline filters these.)
_WHISPER_HALLUCINATIONS = {
    "you", "thank you", "thanks", "thank you for watching",
    "thanks for watching", "thank you so much", "bye", "bye-bye",
    "subscribe", "please subscribe", "see you next time",
    "see you in the next video", "uh", "um", "okay", "oh", "so", "the",
}


def _transcribe(audio: np.ndarray, initial_prompt: str | None = None,
                strict: bool = False) -> str:
    """Transcribe audio with Whisper.  Uses _WHISPER_DOMAIN_PROMPT by default.
    Applies _phonetic_normalize() to the result (Item 6).

    v1.11 `strict` (used by the open-mic session listener): rejects low-
    confidence output using Whisper's own per-segment statistics —
    no_speech_prob (how sure the model is the audio ISN'T speech) and
    avg_logprob (how confident the transcription is) — plus the hallucination
    blacklist.  This is what stops ambient noise becoming "random words"."""
    # Model loads in the background at boot — wait briefly if it isn't up yet.
    if not _STT_READY.wait(timeout=25.0):
        _logger.warning("Transcribe skipped — Whisper model still loading.")
        return ""
    # Downsample native hardware rate → 16 kHz for Whisper (pure NumPy).
    audio_16k = _resample_to_16k(audio.flatten().astype(np.float32), SAMPLE_RATE)
    f32       = audio_16k / 32768.0
    prompt = initial_prompt if initial_prompt is not None else _WHISPER_DOMAIN_PROMPT
    # Language: always None (v1.51) → Whisper auto-detects the spoken language on
    # every utterance, independent of the interface language. Needs a multilingual
    # model (whisper_models() returns one); English-only .en models can't detect.
    try:
        import wivexx_i18n as _i18n
        _stt_lang = _i18n.stt_language()
    except Exception:
        _stt_lang = "en"
    kwargs: dict = dict(
        language=_stt_lang,
        vad_filter=True,
        vad_parameters={"min_silence_duration_ms": 300},
    )
    if prompt:
        kwargs["initial_prompt"] = prompt
    # Lock: PTT thread, voice loop and model swaps must never overlap.
    with _STT_LOCK:
        segs, _info = _STT.transcribe(f32, **kwargs)
        seg_list = list(segs)          # consume the generator under the lock

    if strict and seg_list:
        kept = [s for s in seg_list
                if getattr(s, "no_speech_prob", 0.0) < 0.55]
        if kept:
            avg_lp = sum(getattr(s, "avg_logprob", 0.0) for s in kept) / len(kept)
            if avg_lp < -1.2:
                _logger.debug("Transcribe rejected: avg_logprob=%.2f", avg_lp)
                kept = []
        else:
            _logger.debug("Transcribe rejected: all segments no_speech")
        seg_list = kept

    raw = " ".join(s.text for s in seg_list).strip()
    if strict and raw.lower().strip(" .,!?") in _WHISPER_HALLUCINATIONS:
        _logger.debug("Transcribe rejected: hallucination %r", raw)
        raw = ""
    result = _phonetic_normalize(raw) if raw else ""
    _logger.debug(f"Whisper raw='{raw}'  normalized='{result}'")
    return result


# ══════════════════════════════════════════════════════════════════════════════
#  MIC CALIBRATION
# ══════════════════════════════════════════════════════════════════════════════
def _load_calibration() -> dict:
    try:
        if os.path.exists(CALIB_FILE):
            with open(CALIB_FILE) as f:
                return json.load(f)
    except Exception:
        pass
    return {}

def _save_calibration(device_idx: Optional[int], device_name: str,
                      rms: float, voice_sample: str) -> None:
    data = {"device_index": device_idx, "device_name": device_name,
            "rms_baseline": round(rms, 1), "voice_sample": voice_sample, "calibrated": True}
    try:
        with open(CALIB_FILE, "w") as f:
            json.dump(data, f, indent=2)
        print(f"[Calibration] Saved -> {CALIB_FILE}")
    except Exception as e:
        print(f"[Calibration] Could not save: {e}")

def _run_mic_test() -> None:
    calib = _load_calibration()
    if calib.get("calibrated"):
        print(f"[Mic] Calibrated — [{calib.get('device_index')}] {calib.get('device_name','')}")
        if calib.get("voice_sample"):
            print(f"[Mic] Voice sample: '{calib['voice_sample']}'")
        print()
        return

    print("-- First-time microphone calibration -------------------")
    default_idx = sd.default.device[0]
    for i, d in enumerate(sd.query_devices()):
        if d["max_input_channels"] > 0:
            print(f"  [{i}] {d['name']}{' << DEFAULT' if i == default_idx else ''}")
    print()
    print("[Mic] Say anything -- waiting up to 20 s...")

    audio = _record_triggered(max_wait=20.0, post_secs=3.0)
    if audio is None:
        print("[Mic] No speech detected -- check Windows Settings -> Sound -> Input.\n")
        return

    rms  = float(np.sqrt(np.mean(audio.astype(np.float32) ** 2)))
    text = _transcribe(audio)

    if rms < 80:
        print(f"[Mic] [X] VERY LOW signal ({rms:.0f} RMS). Set MIC_DEVICE in wivexx_config.py.\n")
        return

    status = "✓ Good" if rms >= 400 else "⚠ Low"
    print(f"[Mic] {status} signal ({rms:.0f} RMS)")
    print(f"[Mic] Heard: '{text}'")

    dev_idx = _get_device() or sd.default.device[0]
    dev_name = sd.query_devices(dev_idx)["name"]
    _save_calibration(dev_idx, dev_name, rms, text)
    print("[Mic] Calibration saved — skipped on future runs.\n")


# ══════════════════════════════════════════════════════════════════════════════
#  WEB SEARCH
# ══════════════════════════════════════════════════════════════════════════════
def _needs_search(question: str) -> bool:
    return any(kw in question.lower() for kw in SEARCH_TRIGGERS)


# v1.34: the model signalling (or promising) a live search. The [SEARCH] token
# is the sanctioned protocol; the phrase patterns are the safety net for when
# the model narrates instead ("permítame realizar una búsqueda web…") — those
# promises used to be dead ends: no search ever ran and the user waited forever.
_SEARCH_PROMISE_RE = re.compile(
    r"\[SEARCH\]"
    r"|\b(?:let me|allow me to|i(?:'ll| will| shall)(?: now)?)\s+"
    r"(?:search|look\s+(?:it|that|this)?\s*up|check\s+online|run\s+a\s+search|"
    r"perform(?:ing)?\s+(?:a|the)\s+(?:web\s+)?search)"
    r"|\bperforming\s+the\s+search\b"
    r"|\bsearch(?:ing)?\s+the\s+web\b"
    r"|perm[ií]tame\s+(?:realizar|hacer|efectuar)\s+(?:una\s+)?b[uú]squeda"
    r"|(?:realizar[ée]?|har[ée]|voy\s+a\s+(?:realizar|hacer))\s+(?:una\s+)?b[uú]squeda"
    r"|buscar[ée]\s+(?:en\s+)?(?:l[ií]nea|internet|la\s+web)"
    r"|d[ée]j[ae]me\s+buscar",
    re.IGNORECASE,
)


def _wants_search_retry(answer: str) -> bool:
    """True when the reply asks for / promises a web search instead of answering."""
    return bool(answer) and bool(_SEARCH_PROMISE_RE.search(answer))


def _extract_search_query(question: str) -> str:
    if not _HAS_API:
        # No key → the Claude-Haiku query refinement is unavailable, but we must
        # STILL search (the local brain relies on _search_web for grounding).
        # Returning "" here used to send an EMPTY query to DuckDuckGo, silently
        # killing web search on the no-API path. Instead, do a light offline
        # cleanup so DuckDuckGo / Wikipedia get lean keywords instead of a whole
        # natural-language question (a long question made Wikipedia mismatch),
        # and always fall back to the full question if the cleanup empties it.
        q = question.strip()
        cleaned = re.sub(
            r"^(?:hey\s+wivexx[,\s]*)?(?:please\s+|so\s+|okay\s+|ok\s+|and\s+)?"
            r"(?:can|could|would|will)\s+you\s+(?:please\s+)?(?:tell\s+me\s+|"
            r"look\s+up\s+|find\s+out\s+)?"
            r"|^(?:what|who|when|where|which|how)(?:'s|\s+is|\s+are|\s+was|\s+were)?"
            r"\s+(?:the\s+|a\s+|much\s+|many\s+)?"
            r"|^(?:tell|give|show)\s+me\s+(?:about\s+)?",
            "", q, flags=re.IGNORECASE).strip()
        cleaned = re.sub(r"\s*(?:,?\s*(?:right\s+now|today|currently|these\s+days|"
                         r"please))\s*\??$", "", cleaned, flags=re.IGNORECASE).strip(" ?.!,")
        return cleaned or q
    context = ""
    for msg in reversed(_conversation):
        if msg["role"] == "assistant":
            text = msg.get("content", "")
            if isinstance(text, str) and text:
                context = f"Last response: {text[:300]}\n\n"
            break

    prompt = (
        f"{context}"
        f'User said: "{question}"\n\n'
        f"What is the exact web search query needed to answer this? "
        f"Reply with ONLY the search query (2-6 words, no punctuation). "
        f"If pronouns like 'it' or 'that' are used, resolve them from context."
    )

    try:
        resp = _client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=25,
            messages=[{"role": "user", "content": prompt}],
        )
        extracted = resp.content[0].text.strip().strip('"\'').rstrip(".")
        if extracted and 2 < len(extracted) < 80:
            print(f"[Search] Query: '{extracted}'")
            return extracted
    except Exception as e:
        print(f"[Search] Query extraction failed: {e}")

    return question.strip()


def _search_ddg_html(query: str, max_results: int = 4) -> Optional[str]:
    try:
        encoded = urllib.parse.quote_plus(query)
        url = f"https://html.duckduckgo.com/html/?q={encoded}"
        req = urllib.request.Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            "Accept-Language": "en-US,en;q=0.9",
        })
        with urllib.request.urlopen(req, timeout=10) as resp:
            html = resp.read().decode("utf-8", errors="ignore")

        titles   = re.findall(r'class="result__a"[^>]*>([^<]+(?:<[^/][^>]*>[^<]*</[^>]+>)*[^<]*)</a>', html)
        snippets = re.findall(r'class="result__snippet"[^>]*>(.*?)</a>', html, re.DOTALL)

        def _strip(text: str) -> str:
            return re.sub(r"<[^>]+>", "", text).strip()

        parts = []
        for i in range(min(max_results, len(titles), len(snippets))):
            t = _strip(titles[i])
            s = _strip(snippets[i])
            if t or s:
                parts.append(f"[{t}] {s}")

        if parts:
            print(f"[Search] {len(parts)} results via DuckDuckGo HTML.")
            return "\n\n".join(parts)
    except Exception as e:
        print(f"[Search] DDG HTML scrape: {e}")
    return None


def _search_ddg_lite(query: str, max_results: int = 4) -> Optional[str]:
    """Fallback scrape of lite.duckduckgo.com — a lighter, table-layout endpoint
    that frequently still answers when html.duckduckgo.com returns nothing
    (different backend, less aggressive rate-limiting). Since the installed
    duckduckgo_search (2.x, ddg() only) has no working DDGS class, these HTML
    endpoints are the app's real search backends — so we keep two of them."""
    try:
        encoded = urllib.parse.quote_plus(query)
        url = f"https://lite.duckduckgo.com/lite/?q={encoded}"
        req = urllib.request.Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            "Accept-Language": "en-US,en;q=0.9",
        })
        with urllib.request.urlopen(req, timeout=10) as resp:
            html = resp.read().decode("utf-8", errors="ignore")

        titles   = re.findall(r'class=["\']result-link["\'][^>]*>(.*?)</a>', html, re.DOTALL)
        snippets = re.findall(r'class=["\']result-snippet["\'][^>]*>(.*?)</td>', html, re.DOTALL)

        def _strip(text: str) -> str:
            return re.sub(r"<[^>]+>", "", text).strip()

        parts = []
        for i in range(min(max_results, len(snippets))):
            s = _strip(snippets[i])
            t = _strip(titles[i]) if i < len(titles) else ""
            if s:
                parts.append(f"[{t}] {s}" if t else s)

        if parts:
            print(f"[Search] {len(parts)} results via DuckDuckGo Lite.")
            return "\n\n".join(parts)
    except Exception as e:
        print(f"[Search] DDG Lite scrape: {e}")
    return None


def _search_web(question: str, max_results: int = 4) -> Optional[str]:
    query = _extract_search_query(question)

    try:
        from duckduckgo_search import DDGS  # type: ignore
        with DDGS() as ddgs:
            results = list(ddgs.text(query, max_results=max_results))
        if results:
            parts = [
                f"[{r.get('title','').strip()}] {r.get('body','').strip()}"
                for r in results if r.get("title") or r.get("body")
            ]
            combined = "\n\n".join(parts)
            if combined:
                print(f"[Search] {len(results)} results via duckduckgo_search.")
                return combined
    except ImportError:
        pass
    except Exception as e:
        print(f"[Search] duckduckgo_search: {e}")

    result = _search_ddg_html(query, max_results)
    if result:
        return result

    result = _search_ddg_lite(query, max_results)
    if result:
        return result

    try:
        enc = urllib.parse.quote_plus(query)
        url = f"https://api.duckduckgo.com/?q={enc}&format=json&no_html=1&skip_disambig=1"
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=8) as resp:
            data = json.loads(resp.read().decode())
        parts = []
        if data.get("AbstractText"):
            parts.append(f"[{data.get('AbstractSource', '')}] {data['AbstractText']}")
        for topic in data.get("RelatedTopics", [])[:3]:
            if isinstance(topic, dict) and topic.get("Text"):
                parts.append(topic["Text"])
        if parts:
            print(f"[Search] {len(parts)} results via DuckDuckGo API.")
            return "\n\n".join(parts)
    except Exception as e:
        print(f"[Search] DuckDuckGo API: {e}")

    looks_encyclopedic = any(w in query.lower() for w in [
        "who is", "what is", "history of", "born", "founded", "president",
        "capital", "population", "definition", "meaning",
    ])
    has_product_words = any(w in query.lower() for w in [
        "mod", "modpack", "game", "app", "software", "plugin", "addon",
        "dlc", "update", "patch", "version", "download", "install",
    ])
    if looks_encyclopedic or not has_product_words:
        try:
            enc = urllib.parse.quote_plus(query)
            search_url = (
                f"https://en.wikipedia.org/w/api.php?action=query&list=search"
                f"&srsearch={enc}&srlimit=1&format=json"
            )
            req = urllib.request.Request(search_url, headers={"User-Agent": "Wivexx/1.0"})
            with urllib.request.urlopen(req, timeout=8) as resp:
                hits = json.loads(resp.read().decode()).get("query", {}).get("search", [])

            if hits:
                title = hits[0]["title"]
                title_enc = urllib.parse.quote(title.replace(" ", "_"))
                req2 = urllib.request.Request(
                    f"https://en.wikipedia.org/api/rest_v1/page/summary/{title_enc}",
                    headers={"User-Agent": "Wivexx/1.0"},
                )
                with urllib.request.urlopen(req2, timeout=8) as resp2:
                    summary = json.loads(resp2.read().decode())
                extract = summary.get("extract", "")
                if extract:
                    print(f"[Search] Wikipedia: '{title}'")
                    return f"[Wikipedia — {title}] {extract[:600]}"
        except Exception as e:
            print(f"[Search] Wikipedia: {e}")
    else:
        print("[Search] Skipping Wikipedia — query looks like a product/mod search.")

    print("[Search] All methods failed — answering from training data.")
    return None


# ══════════════════════════════════════════════════════════════════════════════
#  SCREEN CAPTURE
# ══════════════════════════════════════════════════════════════════════════════
_FUZZY_SCREEN_CANONICAL    = [
    "screenshot", "screen capture", "take a picture of screen",
    "capture screen", "show me the screen",
]
_FUZZY_DUAL_CANONICAL      = [
    "both monitors", "dual monitor", "all screens",
    "every monitor", "all monitors",
]


def _wants_screen(question: str) -> bool:
    """Return True if question asks for a screen capture (exact match first, fuzzy fallback)."""
    q = question.lower()
    # Fast exact-string pass
    if any(phrase in q for phrase in SCREEN_PHRASES):
        return True
    # Fuzzy fallback (Item 2)
    return any(_fuzzy_matches(q, canonical) for canonical in _FUZZY_SCREEN_CANONICAL)


def _wants_multi_monitor(question: str) -> bool:
    """Return True if question asks for multiple monitors (exact match first, fuzzy fallback)."""
    q = question.lower()
    # Fast exact-string pass
    if any(phrase in q for phrase in MULTI_MONITOR_PHRASES):
        return True
    # Fuzzy fallback (Item 2)
    return any(_fuzzy_matches(q, canonical) for canonical in _FUZZY_DUAL_CANONICAL)

def _get_monitor_mode(question: str) -> str:
    q = question.lower()
    if any(phrase in q for phrase in BOTH_MONITORS_PHRASES):
        return "all"
    if any(phrase in q for phrase in SECONDARY_MONITOR_PHRASES):
        return "secondary"
    return "primary"


def _flash_screen_border(
    left: int = 0, top: int = 0,
    width: int = 1920, height: int = 1080,
    duration_ms: int | None = None,
) -> None:
    if duration_ms is None:
        duration_ms = getattr(cfg, "FLASH_DURATION_MS", 600)  # Item 7
    """Flash a cyan border on a specific monitor.
    Designed to be called directly from a daemon thread per monitor."""
    def _run(x: int, y: int, w: int, h: int, dur: int) -> None:
        try:
            import ctypes
            import ctypes.wintypes as wt

            BORDER = 16
            STEPS  = 24
            STEP_MS = max(10, dur // STEPS)

            user32  = ctypes.windll.user32
            gdi32   = ctypes.windll.gdi32
            kernel32= ctypes.windll.kernel32

            WS_POPUP            = 0x80000000
            WS_EX_TOPMOST       = 0x00000008
            WS_EX_LAYERED       = 0x00080000
            WS_EX_TRANSPARENT   = 0x00000020
            WS_EX_NOACTIVATE    = 0x08000000
            WS_EX_TOOLWINDOW    = 0x00000080
            ex_style = WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW

            HWND_TOPMOST        = -1
            SWP_NOACTIVATE      = 0x0010
            SWP_NOMOVE          = 0x0002
            SWP_NOSIZE          = 0x0001
            SW_SHOWNOACTIVATE   = 4
            LWA_ALPHA           = 0x00000002

            class WNDCLASSEX(ctypes.Structure):
                _fields_ = [
                    ("cbSize",        ctypes.c_uint),
                    ("style",         ctypes.c_uint),
                    ("lpfnWndProc",   ctypes.c_void_p),
                    ("cbClsExtra",    ctypes.c_int),
                    ("cbWndExtra",    ctypes.c_int),
                    ("hInstance",     wt.HINSTANCE),
                    ("hIcon",         wt.HICON),
                    ("hCursor",       wt.HANDLE),
                    ("hbrBackground", wt.HBRUSH),
                    ("lpszMenuName",  wt.LPCWSTR),
                    ("lpszClassName", wt.LPCWSTR),
                    ("hIconSm",       wt.HICON),
                ]

            DefWindowProc = user32.DefWindowProcW
            DefWindowProc.restype  = ctypes.c_long
            DefWindowProc.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]

            WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_long, wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM)
            wnd_proc = WNDPROC(lambda hwnd, msg, wp, lp: DefWindowProc(hwnd, msg, wp, lp))

            hinstance = kernel32.GetModuleHandleW(None)
            class_name = "WivexxFlash_" + str(_next_flash_id())

            wc = WNDCLASSEX()
            wc.cbSize        = ctypes.sizeof(WNDCLASSEX)
            wc.lpfnWndProc   = ctypes.cast(wnd_proc, ctypes.c_void_p)
            wc.hInstance     = hinstance
            wc.lpszClassName = class_name

            if not user32.RegisterClassExW(ctypes.byref(wc)):
                return

            hwnd = user32.CreateWindowExW(
                ex_style, class_name, "", WS_POPUP,
                x, y, w, h, None, None, hinstance, None,
            )
            if not hwnd:
                user32.UnregisterClassW(class_name, hinstance)
                return

            user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)
            user32.SetWindowPos(
                hwnd, HWND_TOPMOST, 0, 0, 0, 0,
                SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
            )

            user32.SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA)
            user32.RedrawWindow(hwnd, None, None, 0x0001 | 0x0100)

            hdc_screen = user32.GetDC(hwnd)
            glow_pen  = gdi32.CreatePen(0, 6,    ctypes.c_uint(0x00666600))
            cyan_pen  = gdi32.CreatePen(0, BORDER, ctypes.c_uint(0x00FFFF00))
            null_brush = gdi32.GetStockObject(5)

            old_pen   = gdi32.SelectObject(hdc_screen, glow_pen)
            old_brush = gdi32.SelectObject(hdc_screen, null_brush)
            gdi32.Rectangle(hdc_screen, BORDER - 6, BORDER - 6, w - BORDER + 6, h - BORDER + 6)

            gdi32.SelectObject(hdc_screen, cyan_pen)
            gdi32.Rectangle(hdc_screen, BORDER // 2, BORDER // 2, w - BORDER // 2, h - BORDER // 2)

            gdi32.SelectObject(hdc_screen, old_pen)
            gdi32.SelectObject(hdc_screen, old_brush)
            gdi32.DeleteObject(glow_pen)
            gdi32.DeleteObject(cyan_pen)
            user32.ReleaseDC(hwnd, hdc_screen)

            for step in range(STEPS + 1):
                alpha = max(0, int(255 * (1.0 - step / STEPS)))
                user32.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)
                while user32.PeekMessageW(ctypes.byref(ctypes.c_int(0)), hwnd, 0, 0, 1):
                    pass
                time.sleep(STEP_MS / 1000.0)

            user32.DestroyWindow(hwnd)
            user32.UnregisterClassW(class_name, hinstance)

        except Exception:
            pass

    _run(left, top, width, height, duration_ms)


def _capture_screens(monitor_mode: str = "primary") -> str:
    with mss.mss() as sct:
        monitor_list = list(sct.monitors)
        if monitor_mode == "all" or len(monitor_list) < 2:
            monitor = monitor_list[0]
            print("[Screen] Capturing ALL monitors.")
            flash_targets = monitor_list[1:]
        elif monitor_mode == "secondary" and len(monitor_list) > 2:
            monitor = monitor_list[2]
            print("[Screen] Capturing secondary monitor only.")
            flash_targets = [monitor_list[2]]
        else:
            monitor = monitor_list[1]
            print("[Screen] Capturing primary monitor only.")
            flash_targets = [monitor_list[1]]
        shot = sct.grab(monitor)
        img  = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")

    for mon in flash_targets:
        threading.Thread(
            target=_flash_screen_border,
            args=(mon.get("left", 0), mon.get("top", 0),
                  mon.get("width", 1920), mon.get("height", 1080)),
            daemon=True,
        ).start()

    max_w = 2560 if monitor_mode == "all" else 1920
    if img.width > max_w:
        img = img.resize((max_w, int(img.height * (max_w / img.width))), Image.LANCZOS)
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=70)
    _logger.info(f"Screen capture triggered — mode={monitor_mode}")
    return base64.standard_b64encode(buf.getvalue()).decode()


# ══════════════════════════════════════════════════════════════════════════════
#  CLAUDE API
# ══════════════════════════════════════════════════════════════════════════════
def _api_call_with_retry(create_kw: dict, max_attempts: int = 3):
    if not _HAS_API:                      # defense in depth — no key, no call
        raise _NoApiKey(NO_API_MSG)
    for attempt in range(max_attempts):
        try:
            return _client.messages.create(**create_kw)
        except (anthropic.AuthenticationError, anthropic.PermissionDeniedError):
            raise
        except anthropic.RateLimitError:
            raise
        except anthropic.APIConnectionError as e:
            if attempt == max_attempts - 1:
                raise
            wait = 2 ** attempt
            _logger.warning(f"API connection error (attempt {attempt+1}/{max_attempts}), retrying in {wait}s: {e}")
            time.sleep(wait)
        except Exception as e:
            if attempt == max_attempts - 1:
                raise
            wait = 2 ** attempt
            _logger.warning(f"API error (attempt {attempt+1}/{max_attempts}), retrying in {wait}s: {e}")
            time.sleep(wait)
    raise RuntimeError("API call failed after all retries")


# ══════════════════════════════════════════════════════════════════════════════
#  3-TIER SQLITE MEMORY ENGINE  (v1.3.1)
#  Tier 1: core_profile    — durable facts about the user (key/value)
#  Tier 2: topic_summaries — rolling per-topic summaries
#  Tier 3: episodic_log    — raw turn-by-turn transcript
#
#  Thread-safety: a single module-level lock serialises ALL database access.
#  Connections are opened per-operation (short-lived) so the engine is safe to
#  call from the voice loop, the FastAPI worker thread, and background daemon
#  consolidation threads simultaneously.
# ══════════════════════════════════════════════════════════════════════════════
_MEMORY_DB_PATH = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "wivexx_memory.db"
)
_MEM_DB_LOCK = threading.Lock()
_MEM_READY   = False


def _mem_connect() -> sqlite3.Connection:
    """Open a short-lived connection. Caller must hold _MEM_DB_LOCK."""
    conn = sqlite3.connect(_MEMORY_DB_PATH, timeout=10)
    conn.execute("PRAGMA journal_mode=WAL;")
    return conn


def _init_memory_db() -> None:
    """Create wivexx_memory.db and the three memory tables if absent.
    v1.10: wivexx_memory_store migrates the schema (importance/source/
    last_used/use_count columns) in place."""
    global _MEM_READY
    try:
        import wivexx_memory_store as _ms
        _ms.set_logger(_logger)
        _ms.ensure_schema()
    except Exception as _ms_e:
        _logger.warning("memory store migration failed: %s", _ms_e)
    try:
        with _MEM_DB_LOCK:
            conn = _mem_connect()
            try:
                conn.executescript(
                    """
                    CREATE TABLE IF NOT EXISTS core_profile (
                        id         INTEGER PRIMARY KEY AUTOINCREMENT,
                        key        TEXT UNIQUE NOT NULL,
                        value      TEXT NOT NULL,
                        updated_at REAL NOT NULL
                    );
                    CREATE TABLE IF NOT EXISTS topic_summaries (
                        id         INTEGER PRIMARY KEY AUTOINCREMENT,
                        topic      TEXT UNIQUE NOT NULL,
                        summary    TEXT NOT NULL,
                        updated_at REAL NOT NULL
                    );
                    CREATE TABLE IF NOT EXISTS episodic_log (
                        id             INTEGER PRIMARY KEY AUTOINCREMENT,
                        ts             REAL NOT NULL,
                        user_text      TEXT,
                        assistant_text TEXT
                    );
                    """
                )
                conn.commit()
            finally:
                conn.close()
        _MEM_READY = True
        _logger.info("Memory engine ready → %s", _MEMORY_DB_PATH)
    except Exception as e:
        _MEM_READY = False
        _logger.warning("Memory DB init failed: %s", e)


def _mem_log_episode(user_text: str, assistant_text: str) -> None:
    """Append one raw turn to episodic_log (Tier 3). Always safe to call."""
    if not _MEM_READY:
        return
    try:
        with _MEM_DB_LOCK:
            conn = _mem_connect()
            try:
                conn.execute(
                    "INSERT INTO episodic_log (ts, user_text, assistant_text) "
                    "VALUES (?, ?, ?)",
                    (time.time(), user_text, assistant_text),
                )
                conn.commit()
            finally:
                conn.close()
        # v1.33: retention — the log was append-only and grew without bound.
        # Trim occasionally (cheap) instead of on every insert.
        if random.random() < 0.02:
            try:
                import wivexx_memory_store as _ms
                _ms.prune_episodic(int(getattr(cfg, "MEMORY_MAX_EPISODES", 2000)))
            except Exception:
                pass
    except Exception as e:
        _logger.debug("episodic_log insert failed: %s", e)


def _mem_fetch_context(max_facts: int = 8, max_topics: int = 4) -> str:
    """Build a compact context string from core_profile + topic_summaries.
    Returns '' when there is nothing worth injecting (token-frugal)."""
    if not _MEM_READY:
        return ""
    try:
        with _MEM_DB_LOCK:
            conn = _mem_connect()
            try:
                facts = conn.execute(
                    "SELECT value FROM core_profile "
                    "ORDER BY updated_at DESC LIMIT ?",
                    (max_facts,),
                ).fetchall()
                topics = conn.execute(
                    "SELECT topic, summary FROM topic_summaries "
                    "ORDER BY updated_at DESC LIMIT ?",
                    (max_topics,),
                ).fetchall()
            finally:
                conn.close()
    except Exception as e:
        _logger.debug("memory context fetch failed: %s", e)
        return ""

    parts: list[str] = []
    if facts:
        parts.append("Known facts about the user: "
                     + "; ".join(f[0] for f in facts) + ".")
    if topics:
        parts.append("Relevant prior topics: "
                     + " ".join(f"[{t[0]}] {t[1]}" for t in topics))
    return " ".join(parts).strip()


# ── v1.6: memory quality controls ──────────────────────────────────────────────
# The old consolidator called the local LLM on EVERY exchange and forced it to
# extract *something*, which filled the DB with random "memories" (weather
# questions, app commands, half-sentences).  The new pipeline:
#   1. Pre-filter: only turns that actually sound like personal facts trigger
#      profile extraction; the session topic refreshes every N turns.  Most
#      turns never touch the LLM at all.
#   2. The prompt explicitly allows (and prefers) returning EMPTY updates, with
#      few-shot examples — modelled on how production assistant memory works.
#   3. Post-filters: category blacklist, length limits, near-duplicate
#      detection, and a hard cap on stored topics.

_WORTH_REMEMBERING_RE = re.compile(
    r"\b(?:my\s+name|call\s+me|i\s*(?:'m|am)\s+(?!sure|not|just|going\s+to\s+ask)"
    r"|i\s+work|i\s+live|i\s+stud(?:y|ied)"
    r"|i\s+(?:like|love|hate|prefer|enjoy|play|use|own|bought|got\s+a|have\s+a|have\s+an)"
    r"|my\s+(?:favorite|favourite|birthday|job|wife|husband|girlfriend|boyfriend"
    r"|dog|cat|pet|car|pc|computer|setup|camera|project|company|boss"
    r"|friend|brother|sister|mom|dad|schedule|routine)"
    r"|remember\s+(?:that|this)|don'?t\s+forget"
    r"|i\s+(?:want|plan|need)\s+to\b)",
    re.IGNORECASE,
)

_MEM_KEY_BLACKLIST = {
    "question", "query", "request", "command", "weather", "time", "date",
    "search", "topic", "conversation", "user", "assistant", "jarvis", "wivexx",
    "current discussion", "discussion",
}

_MEM_VALUE_REJECT_RE = re.compile(
    r"\b(?:asked\s+(?:about|for)|wants\s+to\s+know|requested|searched\s+for"
    r"|wondering\s+about|question\s+about|no\s+(?:facts|information))\b",
    re.IGNORECASE,
)

_mem_turn_counter = 0
_recent_dialogue: list[tuple[str, str]] = []   # last few (user, assistant) turns


def _clean_mem_value(v) -> str:
    v = str(v).strip().replace("**", "")
    return v


def _consolidate_memory_bg(user_text, assistant_text):
    """
    Consolidates conversation details into the long-term memory database.
    Runs entirely in a background thread to prevent blocking the main loop.
    """
    global _mem_turn_counter
    user_clean      = str(user_text).replace("**", "").strip()
    assistant_clean = str(assistant_text).replace("**", "").strip()
    if not user_clean or not assistant_clean:
        return

    # Keep a short rolling window so topic summaries see real context
    _recent_dialogue.append((user_clean, assistant_clean))
    while len(_recent_dialogue) > 6:
        _recent_dialogue.pop(0)

    if _LOW_POWER_MODE:
        return                       # low-power mode: memory engine paused

    if not _OLLAMA_AVAILABLE:
        # v1.33: no local LLM (most public installs) — the memory engine used to
        # silently do NOTHING here. Now a high-precision RULE extractor catches
        # the clearest self-statements ("my name is…", "I live in…", "my dog is
        # called…") with no LLM at all; apply_ops still dedups + reconciles.
        if _WORTH_REMEMBERING_RE.search(user_clean):
            try:
                import wivexx_memory_store as _ms
                _ops = _ms.extract_facts_rules(user_clean)
                if _ops:
                    _counts = _ms.apply_ops(_ops, source="auto")
                    _ms.prune(max_facts=int(getattr(cfg, "MEMORY_MAX_FACTS", 60)))
                    _n = _counts["add"] + _counts["update"]
                    if _n:
                        _logger.info("Memory engine (rule mode): stored %d fact(s).", _n)
            except Exception as _re_exc:
                _logger.debug("rule-based memory extraction failed: %s", _re_exc)
        return

    _mem_turn_counter += 1
    topic_every  = max(1, int(getattr(cfg, "MEMORY_TOPIC_EVERY_N_TURNS", 4)))
    # v1.34: a QUESTION never yields profile facts — asking about MrBeast /
    # a diet / a phone kept getting stored as facts ABOUT THE USER.
    _is_question = bool(
        user_clean.rstrip().endswith("?")
        or re.match(r"^(?:what|who|when|where|why|how|which|could\s+you|can\s+you|"
                    r"do\s+you|does|is|are|tell\s+me|explain|dime|qué|quién|cómo|"
                    r"cuál|podrías?)\b", user_clean.strip(), re.IGNORECASE))
    want_profile = bool(_WORTH_REMEMBERING_RE.search(user_clean)) and not _is_question
    want_topic   = (_mem_turn_counter % topic_every == 0)
    if not want_profile and not want_topic:
        return                       # nothing memory-worthy — skip the LLM call

    dialogue_lines = []
    for u, a in _recent_dialogue[-3:]:
        dialogue_lines.append(f"User: {u}")
        dialogue_lines.append(f"Wivexx: {a[:300]}")
    dialogue_block = "\n".join(dialogue_lines)

    # v1.10 (Mem0-style reconciliation): show the model the EXISTING related
    # memories so corrections become UPDATEs and contradictions become
    # DELETEs — instead of piling up near-duplicate keys.
    try:
        import wivexx_memory_store as _ms_pre
        _related = _ms_pre.find_facts(user_clean, limit=6)
    except Exception:
        _related = []
    related_block = "\n".join(
        f'  - key="{f["key"]}"  value="{f["value"]}"' for f in _related
    ) or "  (none)"

    def _worker():
        _today = _dt.datetime.now().strftime("%A, %Y-%m-%d")
        prompt_content = f"""You maintain the long-term memory of a personal voice assistant.
Read the dialogue and decide what (if anything) should change in permanent memory.
Today is {_today}.

EXISTING MEMORIES related to this conversation:
{related_block}

STRICT RULES:
1. Only store facts the USER explicitly states about themself or their life:
   name, job, devices they own, preferences, people, pets, projects, schedule.
2. NEVER store: questions the user asked, weather/news/time/volume/app
   requests, the assistant's answers, advice or search results, one-off tasks.
3. If an EXISTING memory covers the same thing, output op "update" with that
   SAME key. If the user corrects or denies an existing memory, "update" it
   with the right value (or "delete" it if it is simply wrong).
4. If there is NOTHING worth changing, return exactly:
   {{"memory_ops": [], "topic_updates": {{}}}}
   An empty list is the CORRECT answer most of the time.
5. importance: 1-10 — identity facts (name, who they are) 8-10; preferences
   and gear 5-7; minor details 2-4.
6. topic_updates: at most ONE entry — short title + 1-sentence summary of the
   substantial subject discussed, if any.
7. Resolve relative dates to ABSOLUTE ones using today's date above: store
   "on 2026-07-04", never "tomorrow" or "next friday" (those rot instantly).

7. CRITICAL: facts about OTHER PEOPLE, celebrities, products or topics the user
   merely ASKS about are NOT facts about the user. The user asking "who is
   MrBeast" tells you NOTHING about the user. Facts must come from the USER'S
   OWN statements about THEMSELF — never from your answers.
8. NEVER store anything about the assistant itself (its name is Wivexx — that
   is not the user's name).

Examples:
Dialogue: User: what's the weather in miami
Output: {{"memory_ops": [], "topic_updates": {{}}}}
Dialogue: User: who is mrbeast? / Wivexx: MrBeast, real name Jimmy Donaldson, is a YouTuber…
Output: {{"memory_ops": [], "topic_updates": {{"MrBeast": "Asked about the YouTuber MrBeast."}}}}
Dialogue: User: tell me about the iphone 15 pro max
Output: {{"memory_ops": [], "topic_updates": {{}}}}
Dialogue: User: my name is David and I shoot with a Canon R6
Output: {{"memory_ops": [{{"op": "add", "key": "name", "value": "David", "importance": 9}}, {{"op": "add", "key": "camera", "value": "Canon R6", "importance": 6}}], "topic_updates": {{}}}}
Existing: key="gaming account" value="benjamin7007"
Dialogue: User: actually my gaming account is vegeta777, not benjamin7007
Output: {{"memory_ops": [{{"op": "update", "key": "gaming account", "value": "vegeta777", "importance": 6}}], "topic_updates": {{}}}}

Dialogue:
{dialogue_block}

Output STRICTLY valid JSON with the keys "memory_ops" and "topic_updates"."""

        payload = {
            "model": _OLLAMA_MODEL,
            "prompt": prompt_content,
            "format": "json",
            "stream": False,
        }

        try:
            req = urllib.request.Request(
                f"{_OLLAMA_BASE_URL}/api/generate",
                data=json.dumps(payload).encode("utf-8"),
                headers={"Content-Type": "application/json"},
            )
            with urllib.request.urlopen(req, timeout=20) as response:
                res_data = json.loads(response.read().decode("utf-8"))
                response_text = res_data.get("response", "{}").strip()
                if response_text.startswith("```json"):
                    response_text = response_text[7:]
                if response_text.startswith("```"):
                    response_text = response_text[3:]
                if response_text.endswith("```"):
                    response_text = response_text[:-3]
                parsed_memory = json.loads(response_text.strip())

            if not isinstance(parsed_memory, dict):
                _logger.warning(
                    "_consolidate_memory_bg: parsed_memory is not a dict (%r) — aborting.",
                    type(parsed_memory))
                return

            # v1.10: ops-based reconciliation via the memory store
            memory_ops    = parsed_memory.get("memory_ops", [])
            topic_updates = parsed_memory.get("topic_updates", {})
            if not isinstance(memory_ops, list):
                memory_ops = []
            # Backward compat: accept the old profile_updates dict as adds
            legacy = parsed_memory.get("profile_updates", {})
            if isinstance(legacy, dict):
                memory_ops += [{"op": "add", "key": k, "value": v}
                               for k, v in legacy.items()]
            if not isinstance(topic_updates, dict):
                topic_updates = {}
            # Enforce the pre-filter decision even if the LLM over-extracted
            if not want_profile:
                memory_ops = []
            if not want_topic:
                topic_updates = {}

            saved_facts = saved_topics = 0
            if memory_ops:
                import wivexx_memory_store as _ms
                # v1.34: a fact must come from the USER'S OWN WORDS — drop ops
                # whose value only appeared in the assistant's answer (that's
                # how "mrbeast: Jimmy Donaldson" became a fact about the user).
                memory_ops = _ms.filter_ops_to_user_text(memory_ops, user_clean)
                counts = _ms.apply_ops(memory_ops, source="auto")
                saved_facts = counts["add"] + counts["update"] + counts["delete"]
                _ms.prune(max_facts=int(getattr(cfg, "MEMORY_MAX_FACTS", 60)))

            now = time.time()
            with _MEM_DB_LOCK:
                conn = _mem_connect()
                try:
                    cur = conn.cursor()
                    # ── Topic summary (single, filtered, capped) ──────────────
                    for topic, summary in list(topic_updates.items())[:1]:
                        t = str(topic).strip()[:100]
                        s = _clean_mem_value(summary)[:300]
                        if not t or not s:
                            continue
                        if t.lower() in _MEM_KEY_BLACKLIST:
                            continue
                        if _MEM_VALUE_REJECT_RE.search(s):
                            continue
                        cur.execute(
                            "INSERT INTO topic_summaries (topic, summary, updated_at) "
                            "VALUES (?, ?, ?) "
                            "ON CONFLICT(topic) DO UPDATE SET summary=excluded.summary, "
                            "updated_at=excluded.updated_at",
                            (t, s, now))
                        saved_topics += 1

                    # Hard cap: keep only the freshest N topics
                    max_topics = int(getattr(cfg, "MEMORY_MAX_TOPICS", 12))
                    cur.execute(
                        "DELETE FROM topic_summaries WHERE id NOT IN ("
                        "  SELECT id FROM topic_summaries "
                        "  ORDER BY updated_at DESC LIMIT ?)",
                        (max_topics,))

                    conn.commit()
                finally:
                    conn.close()

            if saved_facts or saved_topics:
                _logger.info("Memory engine: stored %d fact(s), %d topic(s).",
                             saved_facts, saved_topics)

        except urllib.error.URLError:
            _logger.debug("Memory engine: Ollama not reachable — skipped.")
            _rule_fallback()
        except json.JSONDecodeError as _jde:
            _logger.debug("Memory engine: bad JSON from Ollama: %s", _jde)
            _rule_fallback()
        except Exception as _exc:
            # timeouts on a GPU-starved box land here — don't lose the fact
            _logger.debug("Memory engine: LLM failed (%s) — trying rule extractor.", _exc)
            _rule_fallback()

    def _rule_fallback():
        """v1.34: the LLM path failed mid-turn — salvage the clearest
        self-statements with the rule extractor so the fact isn't lost."""
        if not want_profile:
            return
        try:
            import wivexx_memory_store as _ms
            _ops = _ms.extract_facts_rules(user_clean)
            if _ops:
                _counts = _ms.apply_ops(_ops, source="auto")
                _n = _counts["add"] + _counts["update"]
                if _n:
                    _logger.info("Memory engine (rule fallback): stored %d fact(s).", _n)
        except Exception as _rf_exc:
            _logger.debug("rule fallback failed: %s", _rf_exc)

    threading.Thread(target=_worker, daemon=True, name="wivexx-memory-worker").start()


def _ollama_embed(text):
    """v1.16: local embedding vector for *text* via Ollama, or None when Ollama
    or the embedding model is unavailable.  Powers semantic memory recall —
    when None, retrieval falls back to the lexical scoring (no behaviour change)."""
    if not _OLLAMA_AVAILABLE or not text:
        return None
    model = getattr(cfg, "MEMORY_EMBED_MODEL", "nomic-embed-text")
    try:
        import json as _j
        import urllib.request
        body = _j.dumps({"model": model, "prompt": text[:2000]}).encode()
        req = urllib.request.Request(
            f"{_OLLAMA_BASE_URL}/api/embeddings", data=body,
            headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=12) as r:
            data = _j.loads(r.read().decode())
        emb = data.get("embedding")
        return emb if emb else None
    except Exception as e:
        if _logger:
            _logger.debug("ollama embed failed (%s): %s", model, e)
        return None


def _get_memory_context(question: str = "") -> str:
    """v1.10: relevance-scored retrieval (wivexx_memory_store).  The identity
    core (importance ≥ 8) is always present; the rest of the block is the
    memories that actually match the current question — instead of blindly
    injecting the 12 newest facts into every prompt."""
    if not os.path.exists(_MEMORY_DB_PATH):
        return ""
    try:
        import wivexx_memory_store as _ms
        return _ms.relevant_context(question)
    except Exception as _mem_read_err:
        _logger.debug("Memory read skipped: %s", _mem_read_err)
        return ""


# ══════════════════════════════════════════════════════════════════════════════
#  LOCAL "TEMPORARY BRAIN" (v1.38) — answer WITHOUT an Anthropic key
# ──────────────────────────────────────────────────────────────────────────────
# Every LOCAL command (timers, apps, volume, screenshots, memory, media, window
# control…) already works with no key. Only free-form conversation needed Claude.
# This gives a graceful fallback so Wivexx can still THINK for people who haven't
# added a key, in three tiers, best first:
#   1. A local LLM via OLLAMA (free, offline, private) — real Q&A in the Wivexx
#      persona + memory + language, and it can be grounded with live web results.
#   2. A zero-setup WEB-SNIPPET answer for factual / current questions (no LLM).
#   3. The friendly setup guide (now also points at the free local-brain option).
_EMBED_MODEL_HINTS = ("embed", "nomic", "bge", "minilm", "e5-")
_ollama_chat_model_cache = {"name": None, "checked_at": 0.0}


def _ollama_chat_model() -> Optional[str]:
    """Best available Ollama CHAT model (excludes embedding models), or None.
    Prefers small, capable, well-known instruct models. Cached ~60 s."""
    now = time.time()
    if _ollama_chat_model_cache["name"] and now - _ollama_chat_model_cache["checked_at"] < 60:
        return _ollama_chat_model_cache["name"]
    if not _OLLAMA_AVAILABLE:
        return None
    try:
        with urllib.request.urlopen(
                urllib.request.Request(f"{_OLLAMA_BASE_URL}/api/tags"), timeout=3) as r:
            tags = json.loads(r.read().decode("utf-8"))
        names = [m.get("name", "") for m in tags.get("models", []) if m.get("name")]
    except Exception:
        return None
    chat = [n for n in names if not any(h in n.lower() for h in _EMBED_MODEL_HINTS)]
    if not chat:
        _ollama_chat_model_cache.update(name=None, checked_at=now)
        return None
    prefer = ("llama3.2", "llama3.1", "qwen2.5", "qwen2", "gemma2", "phi3.5",
              "phi3", "mistral", "llama3")
    pick = next((n for p in prefer for n in chat if n.lower().startswith(p)),
                _OLLAMA_MODEL if _OLLAMA_MODEL in chat else chat[0])
    _ollama_chat_model_cache.update(name=pick, checked_at=now)
    return pick


def _local_brain_available() -> bool:
    return bool(_OLLAMA_AVAILABLE) and _ollama_chat_model() is not None


def _ask_local_brain(question: str, include_search: bool = False) -> str:
    """Answer with the local Ollama LLM in the Wivexx persona (memory + language
    aware, optionally grounded with live web results). Returns '' on failure."""
    global _conversation
    model = _ollama_chat_model()
    if not model:
        return ""
    _system = SYSTEM_PROMPT
    _mem = _get_memory_context(question)
    if _mem:
        _system += _mem
    try:
        import wivexx_i18n as _i18n
        _system += _i18n.system_instruction()
    except Exception:
        pass
    _system += ("\n\nYou are running as a local model, so keep answers short and "
                "confident. If web results are provided below, use them for "
                "current facts; otherwise answer from your own knowledge and be "
                "honest if you're unsure.")
    if include_search:
        try:
            web = _search_web(question)
            if web:
                _system += ("\n\n[Live web results — use these for anything "
                            "current]\n" + web[:2000])
        except Exception:
            pass
    # recent conversation as plain-text chat turns (flatten any image blocks)
    msgs = [{"role": "system", "content": _system}]
    for m in _conversation[-(cfg.MEMORY_TURNS * 2):]:
        c = m.get("content", "")
        if isinstance(c, list):
            c = " ".join(b.get("text", "") for b in c if isinstance(b, dict))
        if c:
            msgs.append({"role": m.get("role", "user"), "content": c})
    msgs.append({"role": "user", "content": question})
    try:
        body = json.dumps({
            "model": model, "messages": msgs, "stream": False,
            "options": {"temperature": 0.6, "num_predict": 320},
        }).encode("utf-8")
        req = urllib.request.Request(f"{_OLLAMA_BASE_URL}/api/chat", data=body,
                                     headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=45) as r:
            data = json.loads(r.read().decode("utf-8"))
        answer = (data.get("message", {}) or {}).get("content", "").strip()
    except Exception as _lb_e:
        _logger.warning("local brain (ollama) failed: %s", _lb_e)
        return ""
    if not answer:
        return ""
    # The local model sometimes emits the WEB-SEARCH protocol token as its entire
    # answer — bare "SEARCH", "[SEARCH]", "**[SEARCH]**". Never return that (the
    # user would hear "search"): if we haven't grounded yet, retry WITH a real web
    # search; if we already searched and it still just asks to search, give up so
    # the caller cascades to the web-snippet tier. Don't pollute history with the
    # throwaway turn.
    if re.fullmatch(r"[\W_]*search[\W_]*", answer, re.IGNORECASE):
        if not include_search:
            return _ask_local_brain(question, include_search=True)
        return ""
    answer = answer.replace("[SEARCH]", "").strip()   # strip any inline token
    if not answer:
        return ""
    _conversation.append({"role": "user", "content": question})
    _conversation.append({"role": "assistant", "content": answer})
    while len(_conversation) > cfg.MEMORY_TURNS * 2:
        _conversation.pop(0)
    _log_turn("user", question)
    _log_turn("assistant", answer)
    try:                                  # same memory learning as the Claude path
        _consolidate_memory_bg(question, answer)
    except Exception:
        pass
    _logger.info("Answered via LOCAL BRAIN (ollama:%s)", model)
    return answer


def _web_snippet_answer(question: str) -> str:
    """Zero-LLM fallback: a butler-wrapped summary of the top web result(s) for a
    factual / current question. Returns '' when nothing useful is found."""
    try:
        web = _search_web(question)
    except Exception:
        web = None
    if not web:
        return ""
    first = web.split("\n\n")[0].strip()
    m = re.match(r"^\[(.*?)\]\s*(.*)$", first, re.DOTALL)
    body = (m.group(2) if m else first).strip()
    if not body:
        return ""
    import html as _html
    body = _html.unescape(body)          # decode &#x27; / &amp; etc. before speaking
    body = body[:400].rsplit(".", 1)[0] + "." if len(body) > 220 else body
    resp = f"From what I found online, sir: {body}"
    try:
        import wivexx_i18n as _i18n
        if _i18n.turn_language() == "es" or _i18n.current_language() == "es":
            resp = f"Según lo que encontré en línea, señor: {body}"
    except Exception:
        pass
    _log_turn("user", question)
    _log_turn("assistant", resp)
    _logger.info("Answered via WEB-SNIPPET fallback (no LLM).")
    return resp


def _no_api_answer(question: str, include_search: bool) -> str:
    """The no-Anthropic-key path: local Ollama brain → web-snippet → setup guide."""
    if _local_brain_available():
        ans = _ask_local_brain(question, include_search=include_search or _needs_search(question))
        if ans:
            return ans
    # factual/current question but no local LLM → try a plain web summary
    if include_search or _needs_search(question):
        ans = _web_snippet_answer(question)
        if ans:
            return ans
    return NO_API_MSG


def _ask_claude(question: str, include_screen: bool = False,
                include_search: bool = False,
                image_b64: str | None = None,
                extra_blocks: list | None = None,
                max_tokens: int | None = None) -> str:
    global _conversation

    if not _HAS_API:                      # no key → local brain / web / setup guide
        return _no_api_answer(question, include_search)

    if include_screen:
        img_b64 = _capture_screens(monitor_mode=_get_monitor_mode(question))
        user_content: list | str = [
            {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": img_b64}},
            {"type": "text", "text": question},
        ]
    elif extra_blocks:
        # v1.9: arbitrary attachment blocks (multiple images / PDFs / text files)
        user_content = list(extra_blocks) + [{"type": "text", "text": question}]
    elif image_b64:
        # v1.6.2: single image attached through the GUI input bar
        user_content = [
            {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}},
            {"type": "text", "text": question},
        ]
    else:
        user_content = question

    work_msgs = list(_conversation) + [{"role": "user", "content": user_content}]
    while len(work_msgs) > cfg.MEMORY_TURNS * 2 + 4:
        work_msgs.pop(0)

    # ── Dynamic memory injection (v1.10: relevance-scored) ────────────────────
    _mem_ctx = _get_memory_context(question)
    _system = SYSTEM_PROMPT + _mem_ctx if _mem_ctx else SYSTEM_PROMPT
    try:                                  # reply in the user's chosen language
        import wivexx_i18n as _i18n
        _system = _system + _i18n.system_instruction()
    except Exception:
        pass

    _max_tokens = max_tokens or getattr(cfg, "RESPONSE_MAX_TOKENS", 300)
    create_kw: dict = dict(
        model=cfg.MODEL,
        max_tokens=_max_tokens,
        system=_system,
        messages=work_msgs,
    )
    if include_search:
        create_kw["tools"] = [{"type": "web_search_20250305", "name": "web_search"}]
        print("[Search] Anthropic web search active...")

    t0 = time.time()
    response = _api_call_with_retry(create_kw)

    for _iter in range(5):
        if response.stop_reason != "tool_use":
            break

        work_msgs.append({"role": "assistant", "content": response.content})

        answered_ids = {
            getattr(b, "tool_use_id", None)
            for b in response.content
            if getattr(b, "type", "") == "tool_result"
        }
        pending = [
            b for b in response.content
            if getattr(b, "type", "") == "tool_use" and b.id not in answered_ids
        ]
        if pending:
            work_msgs.append({
                "role": "user",
                "content": [
                    {"type": "tool_result", "tool_use_id": b.id, "content": ""}
                    for b in pending
                ],
            })
        create_kw["messages"] = work_msgs
        response = _api_call_with_retry(create_kw)

    elapsed_api = time.time() - t0
    _logger.debug(
        f"API call: model={cfg.MODEL}  elapsed={elapsed_api:.2f}s  "
        f"in_tokens={getattr(response.usage, 'input_tokens', '?')}  "
        f"out_tokens={getattr(response.usage, 'output_tokens', '?')}"
    )

    answer = "".join(
        getattr(b, "text", "")
        for b in response.content
        if getattr(b, "type", "") == "text"
    ).strip()

    if not answer:
        answer = "My apologies, sir. I was unable to retrieve an answer."

    if include_screen:
        mem_user: str | list = [{"type": "text", "text": f"[User shared their screen] {question}"}]
    elif extra_blocks:
        mem_user = [{"type": "text",
                     "text": f"[User shared {len(extra_blocks)} file(s)] {question}"}]
    elif image_b64:
        mem_user = [{"type": "text", "text": f"[User shared an image] {question}"}]
    else:
        mem_user = question
    _conversation.append({"role": "user",      "content": mem_user})
    _conversation.append({"role": "assistant", "content": answer})
    while len(_conversation) > cfg.MEMORY_TURNS * 2:
        _conversation.pop(0)

    # Append to conversation log (for export with timestamps)
    _log_turn("user", question)
    _log_turn("assistant", answer)

    # Feature 4: record token usage
    try:
        _record_usage(
            getattr(response.usage, "input_tokens",  0),
            getattr(response.usage, "output_tokens", 0),
        )
    except Exception:
        pass

    # v1.3.1: background memory consolidation (non-blocking, daemon thread)
    try:
        _consolidate_memory_bg(question, answer)
    except Exception:
        pass

    return answer



# ══════════════════════════════════════════════════════════════════════════════
#  TOKEN USAGE LEDGER  (Feature 4)
# ══════════════════════════════════════════════════════════════════════════════
import json as _json_mod
import datetime as _dt

_USAGE_FILE    = os.path.join(os.path.dirname(os.path.abspath(__file__)), "usage.json")
_usage_session = {"in": 0, "out": 0, "calls": 0}


def _load_usage() -> dict:
    try:
        with open(_USAGE_FILE, encoding="utf-8") as _uf:
            return _json_mod.load(_uf)
    except Exception:
        return {}


def _save_usage(data: dict) -> None:
    try:
        with open(_USAGE_FILE, "w", encoding="utf-8") as _uf:
            _json_mod.dump(data, _uf, indent=2)
    except Exception:
        pass


def _record_usage(in_tokens: int, out_tokens: int) -> None:
    global _usage_session
    _usage_session["in"]    += in_tokens
    _usage_session["out"]   += out_tokens
    _usage_session["calls"] += 1
    today = _dt.date.today().isoformat()
    data  = _load_usage()
    day   = data.setdefault(today, {"in": 0, "out": 0, "calls": 0})
    day["in"]    += in_tokens
    day["out"]   += out_tokens
    day["calls"] += 1
    _save_usage(data)


def _usage_today() -> dict:
    """v1.16: today's token/cost snapshot for the status overlay."""
    today = _dt.date.today().isoformat()
    day = _load_usage().get(today, {"in": 0, "out": 0, "calls": 0})
    price_in = getattr(cfg, "TOKEN_PRICE_IN", 3.0)
    price_out = getattr(cfg, "TOKEN_PRICE_OUT", 15.0)
    cost = (day["in"] * price_in + day["out"] * price_out) / 1_000_000
    return {"tokens": day["in"] + day["out"], "calls": day["calls"],
            "cost": cost,
            "session_tokens": _usage_session["in"] + _usage_session["out"]}


def _fmt_usd_spoken(usd: float) -> str:
    """$0.0042 → 'less than one cent', $0.38 → 'about 38 cents',
    $3.20 → 'about 3 dollars and 20 cents' — numbers a TTS voice can say."""
    cents = round(usd * 100)
    if cents < 1:
        return "less than one cent"
    d, c = divmod(cents, 100)
    if d == 0:
        return f"about {c} cent{'s' if c != 1 else ''}"
    d_part = f"{d} dollar{'s' if d != 1 else ''}"
    if c == 0:
        return f"about {d_part}"
    return f"about {d_part} and {c} cents"


def _fmt_tokens_spoken(n: int) -> str:
    if n >= 1_000_000:
        return f"about {n / 1_000_000:.1f} million".replace(".0 ", " ")
    if n >= 1_000:
        return f"about {round(n / 1_000)} thousand"
    return str(n)


def _speak_usage() -> str:
    """v1.7: plain-language cost report.  The Anthropic API has no endpoint to
    read the remaining credit balance with a normal key, so WIVEXX reports
    what he CAN measure (today + this month) and says where the balance lives."""
    today        = _dt.date.today()
    data         = _load_usage()
    day          = data.get(today.isoformat(), {"in": 0, "out": 0, "calls": 0})
    price_in     = getattr(cfg, "TOKEN_PRICE_IN",  3.00)
    price_out    = getattr(cfg, "TOKEN_PRICE_OUT", 15.00)

    month_prefix = today.strftime("%Y-%m")
    m_in  = sum(v.get("in", 0)    for k, v in data.items() if k.startswith(month_prefix))
    m_out = sum(v.get("out", 0)   for k, v in data.items() if k.startswith(month_prefix))
    m_calls = sum(v.get("calls", 0) for k, v in data.items() if k.startswith(month_prefix))

    day_cost   = (day["in"] * price_in + day["out"] * price_out) / 1_000_000
    month_cost = (m_in * price_in + m_out * price_out) / 1_000_000

    if day["calls"] == 0:
        first = "You haven't used the API yet today, sir."
    else:
        first = (f"Today you've made {day['calls']} "
                 f"request{'s' if day['calls'] != 1 else ''} using "
                 f"{_fmt_tokens_spoken(day['in'] + day['out'])} tokens — "
                 f"that's {_fmt_usd_spoken(day_cost)}.")
    second = (f" This month so far: {m_calls} requests, "
              f"{_fmt_usd_spoken(month_cost)} in total.")
    third = (" As for what's remaining — your credit balance isn't visible "
             "through the API, sir; you can check it at console.anthropic.com.")
    return first + second + third


_USAGE_QUERY_RE = re.compile(
    r"\b(?:token usage|how many tokens|api usage"
    r"|tokens?\s+(?:used|left|remaining|spent)"
    r"|usage stats?"
    r"|how\s+much\s+(?:have\s+i|did\s+i)\s+spen[dt]"
    r"|api\s+(?:cost|costs|credit|credits|budget|balance|bill|spending)"
    r"|(?:remaining|left\s*over)\s+(?:credit|credits|tokens|budget|balance)"
    r"|how\s+much\s+(?:credit|money|budget)\s+(?:do\s+i\s+have\s+)?(?:left|remaining)?"
    r")\b",
    re.IGNORECASE,
)

# ══════════════════════════════════════════════════════════════════════════════
#  CONVERSATION ENGINE — local command handlers
# ══════════════════════════════════════════════════════════════════════════════
# v1.42: anchored dismiss — the phrase must BE the utterance (after stripping an
# optional wake word, politeness and trailing softeners), not a substring buried
# in speech. The old `phrase in text` ended the chat on "i can't sleep", "stop
# the music", "we need to stop them", "i said goodbye…". We test the exact set
# both before and after the tail-strip so named forms ("thanks jarvis") and
# softened forms ("that's all for now") both match.
_DISMISS_LEAD = re.compile(
    r"^(?:(?:hey|ok|okay|alright|all\s+right|well|um+|uh+)[,\s]+)*"
    r"(?:wivexx|jarvis)?[,\s]*(?:please\s+)?", re.IGNORECASE)
_DISMISS_TAIL = re.compile(
    r"(?:[\s,]+(?:please|now|for\s+now|then|wivexx|jarvis|buddy|man|"
    r"thanks|thank\s+you|ok|okay))*[.!?\s]*$", re.IGNORECASE)
# ── v1.43: Spanish dismiss set + politeness strip (offline-path parity) ────────
_ES_DISMISS_PHRASES = {
    "eso es todo", "es todo", "nada más", "nada mas", "descansa",
    "puedes descansar", "ya puedes descansar", "adiós", "adios",
    "hasta luego", "hasta pronto", "hasta mañana", "hasta manana",
    "buenas noches", "me despido", "eso sería todo", "eso seria todo",
    "ya está bien", "ya esta bien", "ya me voy", "me voy",
    "me tengo que ir", "ya me tengo que ir", "nos vemos",
}
_ES_DISMISS_LEAD = re.compile(
    r"^(?:(?:bueno|ok|okay|oye|este|pues|ya|listo|gracias)[,\s]+)*"
    r"(?:wivexx|jarvis)?[,\s]*(?:por\s+favor\s+)?", re.IGNORECASE)
_ES_DISMISS_TAIL = re.compile(
    r"(?:[\s,]+(?:por\s+ahora|por\s+hoy|por\s+el\s+momento|por\s+favor|"
    r"gracias|wivexx|jarvis|amigo|entonces|ya|pues|"
    r"hasta\s+mañana|hasta\s+manana|hasta\s+luego|hasta\s+pronto|"
    r"adi[oó]s|nos\s+vemos|buenas\s+noches))*[.!?\s]*$", re.IGNORECASE)


def _is_dismiss(text: str) -> bool:
    t1 = _DISMISS_LEAD.sub("", (text or "").lower().strip()).strip(" .,!?")
    if t1 in DISMISS_PHRASES:
        return True
    t2 = _DISMISS_TAIL.sub("", t1).strip(" .,!?")
    if t2 in DISMISS_PHRASES:
        return True
    # v1.43: Spanish dismiss parity. On the offline (no-key) path a Spanish turn
    # is NOT canonicalized to English (quick_english returns None for a wrapped
    # phrase), so it reaches here still in Spanish — the English set above never
    # matches. Mirror the same two-stage anchored strip so "bueno eso es todo por
    # ahora, gracias" dismisses, while conversational uses ("el gato descansa en
    # el sofá") never do (they must be ~the whole utterance).
    s1 = _ES_DISMISS_LEAD.sub("", (text or "").lower().strip()).strip(" .,!?¿¡")
    if s1 in _ES_DISMISS_PHRASES:
        return True
    s2 = _ES_DISMISS_TAIL.sub("", s1).strip(" .,!?¿¡")
    return s2 in _ES_DISMISS_PHRASES


_REPEAT_PHRASES = {
    "repeat that", "say that again", "repeat", "what did you say",
    "pardon", "could you repeat", "can you repeat", "repeat please",
}

_TIME_PHRASES  = {"what time", "what's the time", "time is it", "current time", "tell me the time"}
_DATE_PHRASES  = {"what date", "what's the date", "what day", "today's date", "what is today",
                  "what is the date", "what is the day"}

# ── Whisper filler-word strip (applied before all intent checks) ───────────────
_SPEECH_FILLER_RE = re.compile(
    r'\b(?:um+h?|uh+|like|you\s+know|okay|ok|oh|well|so|hey|hi|yo|right)\b[,.\s]*',
    re.IGNORECASE,
)

# ── Fuzzy phrase matching helpers (Item 2) ─────────────────────────────────────
_FUZZY_FILLER_RE = re.compile(
    r'\b(?:of|my|the|can\s+you|please|could\s+you|would\s+you|both|all)\b',
    re.IGNORECASE,
)


def _normalize(text: str) -> str:
    """Lowercase, strip filler words, strip punctuation — for fuzzy matching."""
    t = text.lower()
    t = re.sub(r'[^\w\s]', ' ', t)
    t = _FUZZY_FILLER_RE.sub(' ', t)
    return re.sub(r'\s+', ' ', t).strip()


def _fuzzy_matches(text: str, canonical: str, threshold: float = 0.78) -> bool:
    """Return True if normalized text fuzzy-matches canonical above threshold."""
    return difflib.SequenceMatcher(None, _normalize(text), _normalize(canonical)).ratio() >= threshold


# Inline tests (run at import time)
# Note: "take a screenshot of both of my screens" is already caught by exact-string
# regex; fuzzy tests cover cases the regex misses.
assert _fuzzy_matches("dual monitor setup", "dual monitor"), \
    "Fuzzy match self-test failed (dual monitor)"
assert _fuzzy_matches("take screenshot please", "screenshot"), \
    "Fuzzy match self-test failed (screenshot)"


# ══════════════════════════════════════════════════════════════════════════════
#  APP LAUNCHER — comprehensive registry
# ══════════════════════════════════════════════════════════════════════════════
# Registry: spoken variant (lowercase) → (display_name, exe_basename, [fallback_full_paths])
# exe_basename is tried via os.startfile() (uses Windows App Paths registry),
# then full paths are tried, then shell start command.
# Use "http..." as exe to open a URL in the default browser instead.
_APP_REGISTRY: dict[str, tuple[str, str, list[str]]] = {}


def _reg_app(display: str, exe: str, paths: list[str], *variants: str) -> None:
    """Register app variants in _APP_REGISTRY."""
    entry: tuple[str, str, list[str]] = (display, exe, paths)
    for v in variants:
        _APP_REGISTRY[v.lower()] = entry


# ── Browsers ──────────────────────────────────────────────────────────────────
_reg_app("Chrome", "chrome.exe",
         [r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"],
         "chrome", "google chrome", "browser", "the browser",
         "open browser", "launch browser", "open the browser")

_reg_app("Firefox", "firefox.exe",
         [r"%ProgramFiles%\Mozilla Firefox\firefox.exe",
          r"%ProgramFiles(x86)%\Mozilla Firefox\firefox.exe"],
         "firefox", "mozilla firefox", "mozilla")

_reg_app("Edge", "msedge.exe",
         [r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe",
          r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"],
         "edge", "microsoft edge", "ms edge")

_reg_app("Brave", "brave.exe",
         [r"%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe"],
         "brave", "brave browser")

_reg_app("Opera", "opera.exe",
         [r"%LOCALAPPDATA%\Programs\Opera\opera.exe",
          r"%LOCALAPPDATA%\Programs\Opera GX\opera.exe"],
         "opera", "opera browser", "opera gx")

# ── Communication ─────────────────────────────────────────────────────────────
_reg_app("Discord", "discord.exe",
         [r"%LOCALAPPDATA%\Discord\app-*\Discord.exe"],
         "discord", "disc", "disc ord", "this cord", "this court",
         "discrd", "discod", "discord app", "discard")

_reg_app("Slack", "slack.exe",
         [r"%LOCALAPPDATA%\slack\slack.exe"],
         "slack", "slack app")

_reg_app("Teams", "msteams.exe",
         [r"%LOCALAPPDATA%\Microsoft\Teams\current\Teams.exe"],
         "teams", "microsoft teams", "ms teams")

_reg_app("Zoom", "zoom.exe",
         [r"%APPDATA%\Zoom\bin\Zoom.exe",
          r"%ProgramFiles%\Zoom\bin\Zoom.exe"],
         "zoom", "zoom app", "zoom meeting")

_reg_app("Telegram", "telegram.exe",
         [r"%APPDATA%\Telegram Desktop\Telegram.exe"],
         "telegram", "telegram app")

_reg_app("Signal", "signal.exe",
         [r"%LOCALAPPDATA%\Programs\signal-desktop\Signal.exe"],
         "signal", "signal app", "signal messenger")

_reg_app("Skype", "skype.exe",
         [r"%ProgramFiles(x86)%\Skype\Phone\Skype.exe"],
         "skype")

_reg_app("WhatsApp", "whatsapp.exe",
         [r"%LOCALAPPDATA%\WhatsApp\WhatsApp.exe",
          r"%APPDATA%\WhatsApp\WhatsApp.exe"],
         "whatsapp", "whats app", "whatsapp desktop")

_reg_app("Viber", "viber.exe",
         [r"%LOCALAPPDATA%\Viber\Viber.exe"],
         "viber")

# ── Gaming ────────────────────────────────────────────────────────────────────
_reg_app("Steam", "steam.exe",
         [r"%ProgramFiles(x86)%\Steam\steam.exe",
          r"%ProgramFiles%\Steam\steam.exe"],
         "steam", "steam client", "steam app")

_reg_app("Epic Games", "epicgameslauncher.exe",
         [r"%ProgramFiles(x86)%\Epic Games\Launcher\Portal\Binaries\Win64\EpicGamesLauncher.exe",
          r"%ProgramFiles%\Epic Games\Launcher\Portal\Binaries\Win64\EpicGamesLauncher.exe"],
         "epic games", "epic games launcher", "epic launcher", "epic")

_reg_app("Riot Client", "riotclientservices.exe",
         [r"%LOCALAPPDATA%\Riot Games\Riot Client\RiotClientServices.exe"],
         "riot client", "riot games", "riot", "valorant", "league of legends", "lol")

_reg_app("Battle.net", "battle.net.exe",
         [r"%ProgramFiles(x86)%\Battle.net\Battle.net.exe",
          r"%ProgramFiles%\Battle.net\Battle.net.exe"],
         "battle.net", "battlenet", "blizzard", "battlenet launcher")

_reg_app("GOG Galaxy", "goggalaxy.exe",
         [r"%ProgramFiles(x86)%\GOG Galaxy\GalaxyClient.exe",
          r"%ProgramFiles%\GOG Galaxy\GalaxyClient.exe"],
         "gog galaxy", "gog", "galaxy")

_reg_app("EA App", "eadesktop.exe",
         [r"%ProgramFiles%\Electronic Arts\EA Desktop\EA Desktop\EADesktop.exe"],
         "ea app", "ea desktop", "origin", "ea", "electronic arts")

_reg_app("Ubisoft Connect", "ubisoftconnect.exe",
         [r"%ProgramFiles(x86)%\Ubisoft\Ubisoft Game Launcher\UbisoftConnect.exe",
          r"%ProgramFiles%\Ubisoft\Ubisoft Game Launcher\UbisoftConnect.exe"],
         "ubisoft connect", "ubisoft", "uplay")

_reg_app("Xbox", "xboxapp.exe",
         [],
         "xbox", "xbox app", "xbox game bar")

_reg_app("Rockstar Launcher", "launcherrocksrar.exe",
         [r"%ProgramFiles%\Rockstar Games\Launcher\Launcher.exe"],
         "rockstar launcher", "rockstar games launcher", "rockstar")

_reg_app("GeForce Experience", "geforceexperience.exe",
         [r"%ProgramFiles%\NVIDIA Corporation\NVIDIA GeForce Experience\NVIDIA GeForce Experience.exe"],
         "geforce experience", "geforce", "nvidia geforce experience", "nvidia")

# ── Music / Media ─────────────────────────────────────────────────────────────
_reg_app("Spotify", "Spotify.exe",
         [r"%APPDATA%\Spotify\Spotify.exe",
          r"%LOCALAPPDATA%\Microsoft\WindowsApps\Spotify.exe"],
         "spotify", "spotify app", "music", "open spotify")

_reg_app("VLC", "vlc.exe",
         [r"%ProgramFiles%\VideoLAN\VLC\vlc.exe",
          r"%ProgramFiles(x86)%\VideoLAN\VLC\vlc.exe"],
         "vlc", "vlc media player", "video player", "vlc player")

_reg_app("iTunes", "itunes.exe",
         [r"%ProgramFiles%\iTunes\iTunes.exe",
          r"%ProgramFiles(x86)%\iTunes\iTunes.exe"],
         "itunes", "apple music")

_reg_app("Winamp", "winamp.exe",
         [r"%ProgramFiles%\Winamp\winamp.exe",
          r"%ProgramFiles(x86)%\Winamp\winamp.exe"],
         "winamp")

_reg_app("foobar2000", "foobar2000.exe",
         [r"%ProgramFiles%\foobar2000\foobar2000.exe"],
         "foobar", "foobar2000")

_reg_app("Windows Media Player", "wmplayer.exe",
         [],
         "media player", "windows media player", "wmp")

_reg_app("PotPlayer", "potplayermini64.exe",
         [r"%ProgramFiles%\DAUM\PotPlayer\PotPlayerMini64.exe",
          r"%ProgramFiles(x86)%\DAUM\PotPlayer\PotPlayerMini64.exe"],
         "potplayer", "pot player", "daum potplayer")

# ── Productivity ──────────────────────────────────────────────────────────────
_reg_app("Notepad", "notepad.exe",
         [],
         "notepad", "note pad", "text editor")

_reg_app("Notepad++", "notepad++.exe",
         [r"%ProgramFiles%\Notepad++\notepad++.exe",
          r"%ProgramFiles(x86)%\Notepad++\notepad++.exe",
          r"%LOCALAPPDATA%\Programs\Notepad++\notepad++.exe"],
         "notepad++", "notepad plus", "notepad plus plus")

_reg_app("Word", "winword.exe",
         [r"%ProgramFiles%\Microsoft Office\root\Office16\WINWORD.EXE",
          r"%ProgramFiles(x86)%\Microsoft Office\root\Office16\WINWORD.EXE"],
         "word", "microsoft word", "ms word")

_reg_app("Excel", "excel.exe",
         [r"%ProgramFiles%\Microsoft Office\root\Office16\EXCEL.EXE",
          r"%ProgramFiles(x86)%\Microsoft Office\root\Office16\EXCEL.EXE"],
         "excel", "microsoft excel", "ms excel", "spreadsheet")

_reg_app("PowerPoint", "powerpnt.exe",
         [r"%ProgramFiles%\Microsoft Office\root\Office16\POWERPNT.EXE",
          r"%ProgramFiles(x86)%\Microsoft Office\root\Office16\POWERPNT.EXE"],
         "powerpoint", "microsoft powerpoint", "ms powerpoint", "power point")

_reg_app("Outlook", "outlook.exe",
         [r"%ProgramFiles%\Microsoft Office\root\Office16\OUTLOOK.EXE",
          r"%ProgramFiles(x86)%\Microsoft Office\root\Office16\OUTLOOK.EXE"],
         "outlook", "microsoft outlook", "ms outlook", "email client")

_reg_app("OneNote", "onenote.exe",
         [r"%ProgramFiles%\Microsoft Office\root\Office16\ONENOTE.EXE"],
         "onenote", "one note", "microsoft onenote")

_reg_app("Notion", "notion.exe",
         [r"%LOCALAPPDATA%\Programs\Notion\Notion.exe",
          r"%APPDATA%\Notion\Notion.exe"],
         "notion", "notion app")

_reg_app("Obsidian", "obsidian.exe",
         [r"%LOCALAPPDATA%\Programs\obsidian\Obsidian.exe",
          r"%APPDATA%\obsidian\Obsidian.exe"],
         "obsidian", "obsidian app")

_reg_app("Trello", "trello.exe",
         [r"%LOCALAPPDATA%\Programs\trello\Trello.exe"],
         "trello")

# ── Dev Tools ─────────────────────────────────────────────────────────────────
_reg_app("VS Code", "code.exe",
         [r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe",
          r"%ProgramFiles%\Microsoft VS Code\Code.exe"],
         "vs code", "vscode", "visual studio code", "code editor", "code")

_reg_app("Visual Studio", "devenv.exe",
         [r"%ProgramFiles%\Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.exe",
          r"%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Community\Common7\IDE\devenv.exe"],
         "visual studio", "vs", "devenv")

_reg_app("PyCharm", "pycharm64.exe",
         [],
         "pycharm", "py charm")

_reg_app("IntelliJ IDEA", "idea64.exe",
         [],
         "intellij", "intellij idea", "idea")

_reg_app("WebStorm", "webstorm64.exe",
         [],
         "webstorm", "web storm")

_reg_app("Android Studio", "studio64.exe",
         [],
         "android studio")

_reg_app("Sublime Text", "sublime_text.exe",
         [r"%ProgramFiles%\Sublime Text\sublime_text.exe",
          r"%ProgramFiles(x86)%\Sublime Text\sublime_text.exe"],
         "sublime", "sublime text")

_reg_app("Git Bash", "git-bash.exe",
         [r"%ProgramFiles%\Git\git-bash.exe",
          r"%ProgramFiles(x86)%\Git\git-bash.exe"],
         "git bash", "gitbash")

_reg_app("Command Prompt", "cmd.exe",
         [],
         "cmd", "command prompt", "command line")

_reg_app("PowerShell", "powershell.exe",
         [],
         "powershell", "power shell")

_reg_app("Windows Terminal", "wt.exe",
         [],
         "windows terminal", "terminal")

_reg_app("Postman", "postman.exe",
         [r"%LOCALAPPDATA%\Programs\Postman\Postman.exe",
          r"%APPDATA%\Postman\Postman.exe"],
         "postman", "postman app")

# ── System ────────────────────────────────────────────────────────────────────
_reg_app("File Explorer", "explorer.exe",
         [],
         "file explorer", "explorer", "files", "my files", "this pc", "my computer")

_reg_app("Task Manager", "taskmgr.exe",
         [],
         "task manager", "taskmanager", "tasks")

_reg_app("Settings", "ms-settings:",
         [],
         "settings", "windows settings", "system settings")

_reg_app("Control Panel", "control.exe",
         [],
         "control panel", "control")

_reg_app("Calculator", "calc.exe",
         [],
         "calculator", "calc")

_reg_app("Paint", "mspaint.exe",
         [],
         "paint", "ms paint", "microsoft paint")

_reg_app("Snipping Tool", "snippingtool.exe",
         [],
         "snipping tool", "snip", "screenshot tool")

_reg_app("Camera", "microsoft.windows.camera:",
         [],
         "camera", "webcam")

# ── Creative ─────────────────────────────────────────────────────────────────
_reg_app("Photoshop", "photoshop.exe",
         [r"%ProgramFiles%\Adobe\Adobe Photoshop 2024\Photoshop.exe",
          r"%ProgramFiles%\Adobe\Adobe Photoshop 2023\Photoshop.exe",
          r"%ProgramFiles%\Adobe\Adobe Photoshop 2025\Photoshop.exe"],
         "photoshop", "adobe photoshop", "ps")

_reg_app("Illustrator", "illustrator.exe",
         [r"%ProgramFiles%\Adobe\Adobe Illustrator 2024\Support Files\Contents\Windows\Illustrator.exe"],
         "illustrator", "adobe illustrator")

_reg_app("Premiere Pro", "adobe premiere pro.exe",
         [r"%ProgramFiles%\Adobe\Adobe Premiere Pro 2024\Adobe Premiere Pro.exe"],
         "premiere", "premiere pro", "adobe premiere", "video editor")

_reg_app("After Effects", "afterfx.exe",
         [r"%ProgramFiles%\Adobe\Adobe After Effects 2024\Support Files\AfterFX.exe"],
         "after effects", "adobe after effects", "ae")

_reg_app("Audacity", "audacity.exe",
         [r"%ProgramFiles%\Audacity\audacity.exe",
          r"%ProgramFiles(x86)%\Audacity\audacity.exe"],
         "audacity", "audio editor")

_reg_app("OBS Studio", "obs64.exe",
         [r"%ProgramFiles%\obs-studio\bin\64bit\obs64.exe",
          r"%ProgramFiles(x86)%\obs-studio\bin\64bit\obs64.exe"],
         "obs", "obs studio", "obs streaming", "open broadcast")

_reg_app("Streamlabs", "streamlabs obs.exe",
         [r"%APPDATA%\slobs-client\Streamlabs OBS.exe"],
         "streamlabs", "streamlabs obs")

_reg_app("Figma", "https://www.figma.com",
         [],
         "figma")

_reg_app("Canva", "https://www.canva.com",
         [],
         "canva")

_reg_app("Lightroom Classic", "lightroom.exe",
         [r"%ProgramFiles%\Adobe\Adobe Lightroom Classic\Lightroom.exe"],
         "lightroom", "lightroom classic", "adobe lightroom")

# ── Web-based / Social ────────────────────────────────────────────────────────
_reg_app("YouTube Music", "https://music.youtube.com",
         [],
         "youtube music", "yt music", "you tube music", "youtube music app")

_reg_app("YouTube", "https://www.youtube.com",
         [],
         "youtube", "you tube")

_reg_app("Instagram", "https://www.instagram.com",
         [],
         "instagram")

_reg_app("Twitter", "https://www.twitter.com",
         [],
         "twitter", "x", "twitter x", "x app")

_reg_app("Reddit", "https://www.reddit.com",
         [],
         "reddit")

_reg_app("Gmail", "https://mail.google.com",
         [],
         "gmail", "google mail")

_reg_app("Google Drive", "https://drive.google.com",
         [],
         "google drive", "drive")

# ── Other Popular ─────────────────────────────────────────────────────────────
_reg_app("7-Zip", "7zfm.exe",
         [r"%ProgramFiles%\7-Zip\7zFM.exe"],
         "7zip", "7-zip", "seven zip")

_reg_app("WinRAR", "winrar.exe",
         [r"%ProgramFiles%\WinRAR\WinRAR.exe",
          r"%ProgramFiles(x86)%\WinRAR\WinRAR.exe"],
         "winrar", "win rar", "rar")

_reg_app("qBittorrent", "qbittorrent.exe",
         [r"%ProgramFiles%\qBittorrent\qbittorrent.exe"],
         "qbittorrent", "q bittorrent", "torrent client", "torrent")

_reg_app("Bitwarden", "bitwarden.exe",
         [r"%LOCALAPPDATA%\Programs\bitwarden\Bitwarden.exe"],
         "bitwarden", "password manager")

_reg_app("KeePass", "keepass.exe",
         [r"%ProgramFiles%\KeePass Password Safe 2\KeePass.exe",
          r"%ProgramFiles(x86)%\KeePass Password Safe 2\KeePass.exe"],
         "keepass", "keepass 2")

_reg_app("TablePlus", "tableplus.exe",
         [r"%LOCALAPPDATA%\Programs\TablePlus\TablePlus.exe"],
         "tableplus", "table plus")

# ── Handle --list-apps early exit ─────────────────────────────────────────────
if _cli_args.list_apps:
    print(f"\nWivexx -- Supported App Names  ({len(_APP_REGISTRY)} variants)")
    print("-" * 60)
    print("Say any variant after 'open', 'launch', 'start',")
    print("or 'can you open' / 'could you launch', etc.\n")
    # Group by display name
    _by_display: dict[str, list[str]] = {}
    for _var, (_disp, _exe, _paths) in _APP_REGISTRY.items():
        _by_display.setdefault(_disp, []).append(_var)
    for _disp in sorted(_by_display):
        _vars = "  |  ".join(sorted(_by_display[_disp]))
        print(f"  {_disp:<28}  {_vars}")
    print()
    sys.exit(0)


# ── App intent detection ───────────────────────────────────────────────────────
# re.search so the verb can appear anywhere: "Oh okay, can you open Discord please"
_APP_INTENT_RE = re.compile(
    r'\b(?:open|launch|start|run)\s+(.+)',
    re.IGNORECASE,
)
# Strip trailing politeness captured as part of the app name
_APP_TRAILING_RE = re.compile(
    r'[,\s]*(?:please|for\s+me|right\s+now|now|thanks?|thank\s+you)[,\s!?.]*$',
    re.IGNORECASE,
)

# Pronouns that indicate a narrative (not a command) when used as verb subject
_NARRATIVE_PRONOUNS = {"i", "we", "they", "he", "she", "it"}


def _is_imperative_command(text: str, target_verbs: list[str]) -> bool:
    """
    Use spaCy dependency parsing to determine if `text` is an imperative
    system command (return True) or a narrative/conversational sentence
    (return False).

    Rules:
      - Rule 1 (Narrative rejection): if a target verb has a nominal subject
        (nsubj) whose lemma is a narrative pronoun → it's a story, return False.
      - Rule 2 (Imperative acceptance): if a target verb is the ROOT token and
        has no narrative pronoun subject → it's a command, return True.
      - Fallback: if spaCy is unavailable, return True (preserve old behaviour).
      - If no target verb is found at all → return True (can't determine;
        let downstream phrase matching / regex decide).
    """
    if not _NLP_AVAILABLE or _nlp is None:
        _logger.debug(f"[DEBUG-TRACE] NLP Gate: '{text}' | Verbs: {target_verbs} | Result: {True}")
        return True  # Graceful degradation — old regex path takes over

    # Take only the first word of multi-word phrases (e.g. "fire up" → "fire")
    targets = {v.split()[0].lower() for v in target_verbs}
    doc = _nlp(text)

    for token in doc:
        if token.pos_ != "VERB":
            continue
        if token.lemma_.lower() not in targets:
            continue

        # ── Rule 1: narrative rejection ──────────────────────────────────────
        for child in token.children:
            if child.dep_ == "nsubj" and child.pos_ == "PRON":
                if child.text.lower() in _NARRATIVE_PRONOUNS:
                    _logger.debug(
                        "NLP: narrative pronoun '%s' → rejected as command", child.text
                    )
                    _logger.debug(f"[DEBUG-TRACE] NLP Gate: '{text}' | Verbs: {target_verbs} | Result: {False}")
                    return False

        # ── Rule 2: imperative acceptance ────────────────────────────────────
        if token.dep_ == "ROOT":
            _logger.debug(
                "NLP: ROOT verb '%s' (lemma=%s) → accepted as command",
                token.text, token.lemma_
            )
            _logger.debug(f"[DEBUG-TRACE] NLP Gate: '{text}' | Verbs: {target_verbs} | Result: {True}")
            return True

        # Verb is non-ROOT but has no narrative subject — still likely a command
        # e.g. "please open Discord" where "open" is xcomp
        has_narrative_subj = any(
            c.dep_ == "nsubj" and c.pos_ == "PRON" and c.text.lower() in _NARRATIVE_PRONOUNS
            for c in token.children
        )
        if not has_narrative_subj:
            _logger.debug(f"[DEBUG-TRACE] NLP Gate: '{text}' | Verbs: {target_verbs} | Result: {True}")
            return True

    # No target verb found at all → can't determine, let phrase matching decide
    _logger.debug(f"[DEBUG-TRACE] NLP Gate: '{text}' | Verbs: {target_verbs} | Result: {True}")
    return True


def _detect_app_intent(question: str) -> Optional[str]:
    """
    Return the app name if the question contains an app-launch request anywhere.
    Handles mid-sentence phrasing and Whisper filler words:
      'open Chrome'                            ✓
      'Can you launch Discord?'                ✓
      'Oh okay okay, can you open Discord please?' ✓
      'Hey Wivexx, go ahead and start Spotify' ✓
    Returns None if no launch intent detected.
    """
    _OPEN_VERBS = ["open", "launch", "start", "run", "load", "fire up", "bring up"]

    # ── NLP gate: reject narratives, accept imperatives ───────────────────────
    if not _is_imperative_command(question, _OPEN_VERBS):
        _logger.debug("NLP gate: '%s' rejected (narrative/question)", question[:60])
        return None

    q = question.strip()
    # 1. Strip Whisper speech fillers
    q = _SPEECH_FILLER_RE.sub(' ', q).strip()
    # 2. Search for launch verb anywhere in the string
    m = _APP_INTENT_RE.search(q)
    if not m:
        return None
    candidate = m.group(1).strip()
    # 3. Remove trailing politeness ("please", "for me", etc.)
    candidate = _APP_TRAILING_RE.sub('', candidate).strip(" .,!?")
    return candidate if candidate else None


def _open_app(app_name: str) -> Optional[str]:
    """
    Attempt to launch an app by its spoken name.
    Returns a spoken response string if the app is known, or None if unrecognized.
    """
    name = app_name.lower().strip(" .,!?")

    # ── 1. Exact match ─────────────────────────────────────────────────────────
    entry = _APP_REGISTRY.get(name)

    # ── 2. Longest-substring match (catches "open discord app" → "discord app") ─
    if not entry:
        best_len = 0
        for variant, data in _APP_REGISTRY.items():
            if variant in name and len(variant) > best_len:
                entry = data
                best_len = len(variant)

    if not entry:
        # ── 3. Auto-discovery: any app installed on THIS PC (Start Menu index) ──
        # Makes "open <app>" work on another PC without pre-registering the app.
        try:
            import wivexx_appindex
            hit = wivexx_appindex.find_app(name)
            if hit and hit.get("lnk"):
                try:
                    os.startfile(hit["lnk"])
                    _logger.info("App launch via Start Menu index: %s", hit["name"])
                    return f"Opening {hit['name']}, sir."
                except Exception as _ai_e:
                    _logger.debug("Start-menu launch failed for %s: %s",
                                  hit.get("name"), _ai_e)
        except Exception as _ai_e:
            _logger.debug("app index lookup failed: %s", _ai_e)
        return None   # unrecognized — fall through to Claude

    display, exe, paths = entry
    _logger.info(f"App launch requested: {display}  (exe={exe})")

    # ── URL / web-based app ────────────────────────────────────────────────────
    if exe.startswith("http"):
        try:
            webbrowser.open(exe)
            return f"Opening {display} in your browser, sir."
        except Exception as e:
            _logger.warning(f"webbrowser.open failed for {display}: {e}")
            return f"I couldn't open {display} in the browser, sir."

    # ── Protocol handler (ms-settings:, microsoft.windows.camera:, etc.) ──────
    if exe.endswith(":"):
        try:
            os.startfile(exe)
            return f"Opening {display}, sir."
        except OSError:
            try:
                subprocess.Popen(
                    ["cmd", "/c", "start", "", exe],
                    creationflags=subprocess.CREATE_NO_WINDOW,
                    close_fds=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    stdin=subprocess.DEVNULL,
                )
                return f"Opening {display}, sir."
            except Exception as e:
                _logger.warning(f"Protocol launch failed for {display}: {e}")
                return f"I couldn't open {display}, sir."

    # ── Strategy 1: os.startfile uses ShellExecute → checks App Paths registry ─
    try:
        os.startfile(exe)
        return f"Opening {display}, sir."
    except OSError:
        pass

    # ── Strategy 2: known common install paths (glob patterns supported) ─────────
    for path_template in paths:
        expanded = os.path.expandvars(path_template)
        if "*" in expanded:
            # Versioned install dirs e.g. %LOCALAPPDATA%\Discord\app-*\Discord.exe
            matches = sorted(_glob.glob(expanded), reverse=True)  # newest first
            for p in matches:
                if os.path.exists(p):
                    try:
                        subprocess.Popen(
                            [p],
                            creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
                            close_fds=True,
                            stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL,
                            stdin=subprocess.DEVNULL,
                        )
                        return f"Opening {display}, sir."
                    except Exception as e:
                        _logger.debug(f"Glob-path launch failed for {display}: {e}")
            continue
        if os.path.exists(expanded):
            try:
                subprocess.Popen(
                    [expanded],
                    creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
                    close_fds=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    stdin=subprocess.DEVNULL,
                )
                return f"Opening {display}, sir."
            except Exception as e:
                _logger.debug(f"Full-path launch failed for {display}: {e}")

    # ── Strategy 2b: Windows App Paths registry ────────────────────────────────
    try:
        import winreg as _winreg
        for _hive in (_winreg.HKEY_CURRENT_USER, _winreg.HKEY_LOCAL_MACHINE):
            try:
                _key = rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
                with _winreg.OpenKey(_hive, _key) as _k:
                    _reg_path = _winreg.QueryValueEx(_k, "")[0].strip('"')
                    if os.path.exists(_reg_path):
                        subprocess.Popen(
                            [_reg_path],
                            creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
                            close_fds=True,
                            stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL,
                            stdin=subprocess.DEVNULL,
                        )
                        return f"Opening {display}, sir."
            except (FileNotFoundError, OSError):
                pass
    except ImportError:
        pass

    # ── Strategy 3: shell start (last resort — catches apps in unusual locations)
    try:
        subprocess.Popen(
            ["cmd", "/c", "start", "", exe],
            creationflags=subprocess.CREATE_NO_WINDOW,
            close_fds=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
        )
        return f"Opening {display}, sir."
    except Exception as e:
        _logger.warning(f"Shell start failed for {display}: {e}")

    # ── Strategy 4: registered protocol handler fallback ──────────────────────
    _PROTO_MAP: dict[str, str] = {
        "discord.exe": "discord://",
        "spotify.exe": "spotify:",
    }
    _proto = _PROTO_MAP.get(exe.lower())
    if _proto:
        try:
            subprocess.Popen(
                ["cmd", "/c", "start", "", _proto],
                creationflags=subprocess.CREATE_NO_WINDOW,
                close_fds=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
            )
            return f"Opening {display}, sir."
        except Exception as e:
            _logger.warning(f"Protocol fallback failed for {display}: {e}")

    return f"{display} doesn't seem to be installed, or I couldn't find it, sir."



# ── Games — individual titles ─────────────────────────────────────────────────
_reg_app("Valorant", "VALORANT-Win64-Shipping.exe",
         [r"%LOCALAPPDATA%\VALORANT\live\ShooterGame\Binaries\Win64\VALORANT-Win64-Shipping.exe"],
         "valorant", "valo")

_reg_app("League of Legends", "LeagueClient.exe",
         [r"%LOCALAPPDATA%\Riot Games\League of Legends\LeagueClient.exe"],
         "league of legends", "league", "lol")

_reg_app("CS2", "cs2.exe",
         [r"%ProgramFiles(x86)%\Steam\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe"],
         "cs2", "csgo", "cs go", "counter strike", "counter-strike 2")

_reg_app("GTA V", "GTA5.exe",
         [r"%ProgramFiles(x86)%\Steam\steamapps\common\Grand Theft Auto V\GTA5.exe",
          r"%ProgramFiles%\Epic Games\GTAV\GTA5.exe",
          r"%ProgramFiles(x86)%\Rockstar Games\Grand Theft Auto V\GTA5.exe"],
         "gta", "gta 5", "gta v", "grand theft auto", "grand theft auto 5")

_reg_app("Fortnite", "FortniteClient-Win64-Shipping.exe",
         [r"%ProgramFiles%\Epic Games\Fortnite\FortniteGame\Binaries\Win64\FortniteClient-Win64-Shipping.exe"],
         "fortnite", "fortnite battle royale")

_reg_app("Apex Legends", "r5apex.exe",
         [r"%ProgramFiles(x86)%\Steam\steamapps\common\Apex Legends\r5apex.exe"],
         "apex", "apex legends")

_reg_app("Call of Duty", "cod.exe",
         [r"%ProgramFiles%\Call of Duty\cod.exe",
          r"%ProgramFiles(x86)%\Activision\Call of Duty\cod.exe"],
         "call of duty", "cod", "modern warfare", "warzone", "call of duty warzone")

_reg_app("Roblox", "RobloxPlayerBeta.exe",
         [r"%LOCALAPPDATA%\Roblox\Versions\RobloxPlayerBeta.exe"],
         "roblox", "roblox player")

# Remove valorant/lol/league from Riot Client variants (now registered separately above)
for _v in ("valorant", "league of legends", "lol", "league"):
    if _v in _APP_REGISTRY and _APP_REGISTRY[_v][0] == "Riot Client":
        del _APP_REGISTRY[_v]

# ── Productivity (expanded) ───────────────────────────────────────────────────
_reg_app("Asana", "https://app.asana.com", [], "asana")
_reg_app("Todoist", "https://todoist.com", [], "todoist")
_reg_app("Linear", "https://linear.app", [], "linear", "linear app")
_reg_app("ClickUp", "https://app.clickup.com", [], "clickup", "click up")
_reg_app("Miro", "https://miro.com", [], "miro")

_reg_app("Loom", "loom.exe",
         [r"%LOCALAPPDATA%\Programs\Loom\Loom.exe"],
         "loom", "loom recorder")

_reg_app("CCleaner", "ccleaner64.exe",
         [r"%ProgramFiles%\CCleaner\CCleaner64.exe",
          r"%ProgramFiles(x86)%\CCleaner\CCleaner64.exe"],
         "ccleaner", "c cleaner")

_reg_app("Everything", "everything.exe",
         [r"%ProgramFiles%\Everything\Everything.exe",
          r"%LOCALAPPDATA%\Programs\Everything\Everything.exe"],
         "everything", "everything search", "file search")

_reg_app("SpeedFan", "speedfan.exe",
         [r"%ProgramFiles%\SpeedFan\speedfan.exe"],
         "speedfan", "speed fan")

_reg_app("HWiNFO", "hwinfo64.exe",
         [r"%ProgramFiles%\HWiNFO64\hwinfo64.exe",
          r"%ProgramFiles(x86)%\HWiNFO32\HWiNFO32.exe"],
         "hwinfo", "hw info", "hwinfo64")

_reg_app("MSI Afterburner", "MSIAfterburner.exe",
         [r"%ProgramFiles(x86)%\MSI Afterburner\MSIAfterburner.exe"],
         "msi afterburner", "afterburner")

_reg_app("CPU-Z", "cpuz_x64.exe",
         [r"%ProgramFiles%\CPUID\CPU-Z\cpuz_x64.exe"],
         "cpu z", "cpu-z", "cpuz")

_reg_app("GPU-Z", "GPU-Z.exe",
         [r"%ProgramFiles%\GPU-Z\GPU-Z.exe"],
         "gpu z", "gpu-z", "gpuz")

_reg_app("MalwareBytes", "mbam.exe",
         [r"%ProgramFiles%\Malwarebytes\Anti-Malware\mbam.exe",
          r"%ProgramFiles(x86)%\Malwarebytes\Anti-Malware\mbam.exe"],
         "malwarebytes", "malware bytes", "malwarebyte", "malware byte")

_reg_app("CurseForge", "curseforge.exe",
         [r"%LOCALAPPDATA%\Programs\curseforge\curseforge.exe",
          r"%APPDATA%\CurseForge\app\CurseForge.exe"],
         "curseforge", "curse forge", "cursed forge")

# ── Creative (expanded) ───────────────────────────────────────────────────────
_reg_app("DaVinci Resolve", "Resolve.exe",
         [r"%ProgramFiles%\Blackmagic Design\DaVinci Resolve\Resolve.exe"],
         "davinci resolve", "davinci", "da vinci resolve", "resolve")

_reg_app("Blender", "blender.exe",
         [r"%ProgramFiles%\Blender Foundation\Blender\blender.exe",
          r"%LOCALAPPDATA%\Programs\Blender Foundation\Blender\blender.exe"],
         "blender")

_reg_app("GIMP", "gimp-2.10.exe",
         [r"%ProgramFiles%\GIMP 2\bin\gimp-2.10.exe",
          r"%ProgramFiles(x86)%\GIMP 2\bin\gimp-2.10.exe"],
         "gimp", "gnu image manipulation")

_reg_app("Inkscape", "inkscape.exe",
         [r"%ProgramFiles%\Inkscape\bin\inkscape.exe",
          r"%ProgramFiles(x86)%\Inkscape\inkscape.exe"],
         "inkscape")

_reg_app("HandBrake", "HandBrake.exe",
         [r"%ProgramFiles%\HandBrake\HandBrake.exe"],
         "handbrake", "hand brake")

_reg_app("MPC-HC", "mpc-hc64.exe",
         [r"%ProgramFiles%\MPC-HC\mpc-hc64.exe",
          r"%ProgramFiles(x86)%\MPC-HC\mpc-hc64.exe"],
         "mpc hc", "mpc-hc", "media player classic")

_reg_app("XSplit", "XSplit.Core.exe",
         [r"%ProgramFiles(x86)%\SplitmediaLabs\XSplit Broadcaster\XSplit.Core.exe"],
         "xsplit", "x split", "xsplit broadcaster")

# ── System tools (expanded) ───────────────────────────────────────────────────
_reg_app("Recycle Bin", "explorer.exe",
         [], "recycle bin", "trash", "recycling bin")

_reg_app("Device Manager", "devmgmt.msc",
         [], "device manager", "devices")

_reg_app("Event Viewer", "eventvwr.msc",
         [], "event viewer", "event log")

_reg_app("Resource Monitor", "resmon.exe",
         [], "resource monitor", "resmon")

_reg_app("Registry Editor", "regedit.exe",
         [], "registry editor", "regedit", "registry")

_reg_app("Disk Management", "diskmgmt.msc",
         [], "disk management", "disk manager")

# ── Music / Entertainment (expanded + web) ────────────────────────────────────
_reg_app("Plex", "Plex.exe",
         [r"%LOCALAPPDATA%\Plex\Plex.exe",
          r"%ProgramFiles%\Plex\Plex.exe"],
         "plex", "plex media server")

_reg_app("Kodi", "kodi.exe",
         [r"%ProgramFiles%\Kodi\kodi.exe",
          r"%ProgramFiles(x86)%\Kodi\kodi.exe"],
         "kodi")

_reg_app("Netflix", "https://www.netflix.com",
         [], "netflix")

_reg_app("Twitch", "https://www.twitch.tv",
         [], "twitch")

_reg_app("Prime Video", "https://www.primevideo.com",
         [], "prime video", "amazon prime", "prime", "amazon prime video")

_reg_app("Disney+", "https://www.disneyplus.com",
         [], "disney plus", "disney+", "disney")

_reg_app("Jellyfin", "https://jellyfin.org",
         [], "jellyfin")

# ── WhatsApp Whisper mishear variants ─────────────────────────────────────────
for _wa_key in ("what's up", "whats up", "what sup", "whatsupp", "what zap"):
    _APP_REGISTRY[_wa_key] = _APP_REGISTRY["whatsapp"]

# ── File Explorer Whisper mishear variants ────────────────────────────────────
for _fe_key in ("file exploder", "file exploiter", "fire explorer", "find explorer"):
    _APP_REGISTRY[_fe_key] = _APP_REGISTRY["file explorer"]


# ══════════════════════════════════════════════════════════════════════════════
#  EXTERNALIZED APP REGISTRY  — wivexx_apps.json (Item 3)
# ══════════════════════════════════════════════════════════════════════════════
_APPS_JSON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx_apps.json")
_APPS_JSON_MTIME: float = 0.0

# Built-in phonetic alias map (hard-coded defaults)
_PHONETIC_ALIASES_BUILTIN: dict[str, str] = {
    "this cord":     "discord",
    "disc cord":     "discord",
    "this court":    "discord",
    "file exploder": "file explorer",
    "file explorer": "file explorer",
    "what's up":     "whatsapp",
    "whats up":      "whatsapp",
    "what sup":      "whatsapp",
    "bs code":       "vs code",
    "vs cord":       "vs code",
    "curse forge":   "curseforge",
    "cloud":         "claude",
    "base code":     "vs code",
    "visual code":   "vs code",
    "space defy":    "spotify",
}

# Merged map (built-in + JSON overrides)
_PHONETIC_ALIASES: dict[str, str] = dict(_PHONETIC_ALIASES_BUILTIN)


# ── User environments (v1.6) — defined in wivexx_apps.json / GUI editor ──────
# {
#   "work": {
#     "display_name": "Work",
#     "variants": ["work", "work environment"],
#     "open":  ["<app name | full path | url | {'path':.., 'args':[..]}>", ...],
#     "close": ["<app name | exe name>", ...]
#   }
# }
_ENVIRONMENTS: dict[str, dict] = {}

# ── Personalised audio-output commands (v1.6.2) — wivexx_apps.json / GUI ─────
# { "change to output 1": {"device_name": "Arctis Nova Pro", "device_id": "{0.0.0...}"} }
_AUDIO_COMMANDS: dict[str, dict] = {}


def _load_apps_json() -> None:
    """Load (or reload) wivexx_apps.json and merge into _APP_REGISTRY,
    _PHONETIC_ALIASES and _ENVIRONMENTS.  Called at startup, every time the
    file changes on disk, and immediately after the GUI command editor saves —
    so new commands are recognised in real time."""
    global _PHONETIC_ALIASES, _APPS_JSON_MTIME, _ENVIRONMENTS, _AUDIO_COMMANDS
    if not os.path.exists(_APPS_JSON_PATH):
        return
    try:
        mtime = os.path.getmtime(_APPS_JSON_PATH)
        _APPS_JSON_MTIME = mtime
        with open(_APPS_JSON_PATH, encoding="utf-8") as _f:
            data = json.load(_f)

        # Merge phonetic_aliases (JSON overrides built-in)
        json_aliases = data.get("phonetic_aliases", {})
        merged = dict(_PHONETIC_ALIASES_BUILTIN)
        merged.update(json_aliases)
        _PHONETIC_ALIASES = merged

        # Merge apps section (JSON takes precedence for display names and paths)
        for _app_key, _app_data in data.get("apps", {}).items():
            _dn   = _app_data.get("display_name", _app_key)
            _exes = [e for e in _app_data.get("exes", []) if str(e).strip()]
            _raw  = _exes[0] if _exes else _app_key + ".exe"
            # v1.6: users save FULL paths from the GUI.  Launching needs the
            # path (kept in the paths list); closing needs the bare exe image
            # name for taskkill — so split them here.
            if _raw.lower().startswith("http") or _raw.endswith(":"):
                _exe, _paths = _raw, []
            elif "\\" in _raw or "/" in _raw:
                _exe, _paths = os.path.basename(_raw), _exes
            else:
                _exe, _paths = _raw, _exes[1:]
            _vars = _app_data.get("variants", [_app_key.lower()])
            _entry: tuple[str, str, list[str]] = (_dn, _exe, _paths)
            for _v in _vars:
                if str(_v).strip():
                    _APP_REGISTRY[str(_v).lower().strip()] = _entry

        # Merge category_aliases.  Guarded: the initial module-level call runs
        # before _CATEGORY_ALIASES is defined further down the file, and a
        # failure here must never abort the environments load below.
        try:
            for _ck, _cv in data.get("category_aliases", {}).items():
                _CATEGORY_ALIASES[_ck.lower()] = _cv
        except NameError:
            pass

        # Load environments (v1.6)
        _envs: dict[str, dict] = {}
        for _ek, _ev in data.get("environments", {}).items():
            if not isinstance(_ev, dict):
                continue
            _envs[_ek.lower()] = {
                "display_name": _ev.get("display_name", _ek.title()),
                "variants": [str(v).lower().strip()
                             for v in _ev.get("variants", [_ek.lower()]) if str(v).strip()],
                "open":  _ev.get("open", []),
                "close": _ev.get("close", []),
            }
        _ENVIRONMENTS = _envs

        # Load personalised audio-output commands (v1.6.2)
        _acmds: dict[str, dict] = {}
        for _ph, _tgt in data.get("audio_commands", {}).items():
            if isinstance(_tgt, dict) and (_tgt.get("device_name") or _tgt.get("device_id")):
                _acmds[str(_ph).lower().strip()] = {
                    "device_name": str(_tgt.get("device_name", "")),
                    "device_id":   str(_tgt.get("device_id", "")),
                }
        _AUDIO_COMMANDS = _acmds

        _logger.info(f"wivexx_apps.json loaded: {len(data.get('apps', {}))} apps, "
                     f"{len(merged)} phonetic aliases, {len(_envs)} environments, "
                     f"{len(_acmds)} audio commands")
    except Exception as _e:
        _logger.warning(f"wivexx_apps.json load error (using defaults): {_e}")


def _phonetic_normalize(text: str) -> str:
    """Replace phonetic mishears with their canonical form using the merged alias map."""
    t_lower = text.lower()
    for mishear, canonical in _PHONETIC_ALIASES.items():
        if mishear in t_lower:
            corrected = re.sub(re.escape(mishear), canonical, t_lower, flags=re.IGNORECASE)
            if corrected != t_lower:
                _logger.debug(f"Phonetic normalize: '{text}' → '{corrected}'")
                return corrected
    return text


def _apps_json_watcher() -> None:
    """Background thread: reload wivexx_apps.json if its mtime changes.
    v1.6: polls every 5 s (was 30 s) so manual file edits apply quickly; GUI
    saves bypass the watcher entirely and reload instantly via callback."""
    while True:
        time.sleep(5)
        try:
            if os.path.exists(_APPS_JSON_PATH):
                mtime = os.path.getmtime(_APPS_JSON_PATH)
                if mtime != _APPS_JSON_MTIME:
                    _logger.info("wivexx_apps.json changed — reloading.")
                    _load_apps_json()
        except Exception as _e:
            _logger.debug(f"apps_json_watcher: {_e}")


# Initial load
_load_apps_json()

# Start watcher thread
threading.Thread(target=_apps_json_watcher, daemon=True).start()


# ══════════════════════════════════════════════════════════════════════════════
#  ENVIRONMENT LAUNCHER (v1.6) — user-defined app/URL bundles
#  "activate work environment" / "close work environment", editable in the GUI.
# ══════════════════════════════════════════════════════════════════════════════
# v1.7: Whisper often inserts punctuation after the verb ("Open, work
# environment.") and sometimes past-tenses it ("Closed work environment") —
# both now tolerated.
_ENV_OPEN_RE = re.compile(
    r'\b(?:activate|start|open(?:ed)?|launch|initialize|initialise|set\s+up'
    r'|setup|load)\s*[,.:]?\s+'
    r'(?:my\s+|the\s+)?(.+?)\s+(?:environment|workspace|setup|mode)\b',
    re.IGNORECASE,
)
_ENV_CLOSE_RE = re.compile(
    r'\b(?:close[d]?|stop|shut\s+down|end|kill|exit|deactivate)\s*[,.:]?\s+'
    r'(?:my\s+|the\s+)?(.+?)\s+(?:environment|workspace|setup|mode)\b',
    re.IGNORECASE,
)


# Filler words people put before an environment name ("close the ENTIRE work
# environment") — stripped so the name still matches.
_ENV_FILLER_RE = re.compile(
    r'\b(?:entire|whole|complete|full|all\s+of|everything\s+in)\b', re.IGNORECASE)


def _match_environment(name: str) -> Optional[str]:
    n = _ENV_FILLER_RE.sub(" ", name.lower())
    n = re.sub(r"\s+", " ", n).strip(" .,!?")
    for key, env in _ENVIRONMENTS.items():
        if n == key or n in env.get("variants", []):
            return key
    # Fuzzy fallback for Whisper mishears
    for key, env in _ENVIRONMENTS.items():
        for v in [key] + env.get("variants", []):
            if _fuzzy_matches(n, v, threshold=0.84):
                return key
    return None


def _detect_environment_intent(question: str) -> Optional[tuple]:
    """Return ("open"|"close", env_key) or None."""
    if not _ENVIRONMENTS:
        return None
    q = _SPEECH_FILLER_RE.sub(" ", question).strip()
    m = _ENV_CLOSE_RE.search(q)
    if m:
        key = _match_environment(m.group(1))
        if key:
            return ("close", key)
    m = _ENV_OPEN_RE.search(q)
    if m:
        key = _match_environment(m.group(1))
        if key:
            return ("open", key)
    return None


_DETACHED_FLAGS = (subprocess.DETACHED_PROCESS
                   | subprocess.CREATE_NEW_PROCESS_GROUP
                   | subprocess.CREATE_NO_WINDOW)


def _launch_env_item(item) -> bool:
    """Open one environment item: registered app name, full path, folder, URL,
    or {"path": "...", "args": [...]} for launches with arguments."""
    try:
        if isinstance(item, dict):
            path = os.path.expandvars(os.path.expanduser(str(item.get("path", ""))))
            args = [str(a) for a in item.get("args", [])]
            if not path or not os.path.exists(path):
                return False
            subprocess.Popen([path] + args, creationflags=_DETACHED_FLAGS,
                             close_fds=True, stdout=subprocess.DEVNULL,
                             stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
            return True
        s = str(item).strip()
        if not s:
            return False
        if s.lower().startswith("http"):
            webbrowser.open(s)
            return True
        # Store / packaged apps (Slack, etc.) by AppUserModelID — launch via the
        # shell so it survives auto-updates (a versioned WindowsApps\…\app.exe path
        # breaks every time the app updates). Accept "shell:AppsFolder\<AUMID>" or
        # the shorthand "aumid:<AUMID>".
        if s.lower().startswith(("shell:", "aumid:")):
            target = s if s.lower().startswith("shell:") \
                else "shell:AppsFolder\\" + s.split(":", 1)[1]
            subprocess.Popen(["explorer.exe", target], close_fds=True)
            return True
        expanded = os.path.expandvars(os.path.expanduser(s))
        if "\\" in expanded or "/" in expanded:
            if os.path.isdir(expanded):
                os.startfile(expanded)
                return True
            if os.path.exists(expanded):
                subprocess.Popen([expanded], creationflags=_DETACHED_FLAGS,
                                 close_fds=True, stdout=subprocess.DEVNULL,
                                 stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
                return True
            return False
        resp = _open_app(s)
        return resp is not None and "doesn't seem" not in resp
    except Exception as e:
        _logger.warning("Environment item launch failed (%r): %s", item, e)
        return False


def _close_env_item(item) -> bool:
    """Close one environment item (URLs are skipped — they can't be killed)."""
    try:
        if isinstance(item, dict):
            s = str(item.get("path", ""))
        else:
            s = str(item).strip()
        if not s or s.lower().startswith("http"):
            return False
        if "\\" in s or "/" in s:
            s = os.path.basename(s)
        if s.lower().endswith(".exe"):
            r = subprocess.run(["taskkill", "/F", "/IM", s],
                               creationflags=subprocess.CREATE_NO_WINDOW,
                               capture_output=True, timeout=5)
            return r.returncode == 0
        return _close_app(s)
    except Exception:
        return False


def _run_environment(env_key: str, action: str) -> str:
    env = _ENVIRONMENTS.get(env_key)
    if not env:
        return "I couldn't find that environment, sir."
    display = env.get("display_name", env_key.title())

    if action == "open":
        items = env.get("open", [])
        if not items:
            return f"The {display} environment has nothing configured to open, sir."
        ok = 0
        for it in items:
            if _launch_env_item(it):
                ok += 1
            time.sleep(0.25)   # slight stagger keeps Windows happy
        _logger.info("Environment '%s' opened: %d/%d items", env_key, ok, len(items))
        if ok == len(items):
            return f"{display} environment initialized, sir. Everything is ready."
        return (f"{display} environment initialized, sir — "
                f"{ok} of {len(items)} items launched.")

    items = env.get("close", []) or env.get("open", [])
    closed = sum(1 for it in items if _close_env_item(it))
    _logger.info("Environment '%s' closed: %d items", env_key, closed)
    if closed:
        return (f"{display} environment closed, sir — "
                f"{closed} application{'s' if closed != 1 else ''} terminated.")
    return f"Nothing from the {display} environment appears to be running, sir."


# ══════════════════════════════════════════════════════════════════════════════
#  AUDIO OUTPUT SWITCHING (v1.6.2) — personalised phrases + generic command
#  "change to output 1" → device chosen in the GUI (⚙ Commands → Audio Outputs)
#  "switch output to arctis" → fuzzy-matched against the live device list
# ══════════════════════════════════════════════════════════════════════════════
# v1.34: accepts every natural word order — "…output to X", "…to output X",
# "…to X output", and "play audio through X" (the old RE only matched the
# first form, so "switch to output secondary" fell through to Claude).
_AUDIO_OUTPUT_RE = re.compile(
    r'\b(?:change|switch|set|move|put|swap)\s+(?:the\s+|my\s+)?(?:audio\s+|sound\s+)?'
    r'output(?:\s+device)?\s+to\s+(.+)$'
    r'|\b(?:change|switch|set|move|put|swap)\s+(?:\w+\s+){0,2}?to\s+(?:the\s+|my\s+)?output\s+(.+)$'
    r'|\b(?:change|switch|set|move|put|swap)\s+(?:\w+\s+){0,2}?to\s+(?:the\s+|my\s+)?(.+?)\s+output$'
    r'|\bplay\s+(?:audio|sound|music|everything)\s+(?:through|on|to)\s+(.+)$',
    re.IGNORECASE,
)


def _switch_audio_output(device_name: str, device_id: str = "") -> Optional[str]:
    """Make the given device the Windows default playback device.
    Returns the friendly name on success, None on failure."""
    try:
        import wivexx_audio_devices as _ad
    except Exception as e:
        _logger.warning("audio device module unavailable: %s", e)
        return None
    dev = None
    if device_id:
        for d in _ad.list_output_devices():
            if d["id"] == device_id:
                dev = d
                break
    if dev is None and device_name:
        dev = _ad.resolve_device(device_name)
    if dev is None:
        _logger.warning("Audio switch: no ACTIVE device matches name=%r id=%r",
                        device_name, device_id)
        return None
    if _ad.set_default_output(dev["id"]):
        _logger.info("Audio output switched → %s", dev["name"])
        # Re-bind WIVEXX's own voice to the new device — SDL keeps streaming
        # to the old one otherwise (and can hang on it; v1.7.1).
        _reinit_tts_mixer()
        return dev["name"]
    _logger.warning("Audio switch: SetDefaultEndpoint failed for %s (%s)",
                    dev["name"], dev["id"])
    return None


def _handle_audio_output_command(question: str) -> Optional[str]:
    """Return a spoken response if *question* is an output-switch command."""
    q = question.lower().strip(" .,!?")
    try:
        import wivexx_audio_devices as _adm
    except Exception:
        _adm = None
    # 1) Personalised phrases from the GUI editor — substring, ORDER-FREE token
    #    match (v1.34: survives reorderings + Whisper self-corrections like
    #    "switch to, change to output to primary"), then whole-string fuzzy
    for phrase, target in _AUDIO_COMMANDS.items():
        if phrase and (phrase in q
                       or (_adm is not None and _adm.phrase_matches(q, phrase))
                       or _fuzzy_matches(q, phrase, threshold=0.85)):
            name = _switch_audio_output(target.get("device_name", ""),
                                        target.get("device_id", ""))
            if name:
                return f"Audio output switched to {name}, sir."
            return ("I couldn't reach that output device, sir. "
                    "Make sure it is connected.")
    # 2) Built-in generic command (any word order; first non-empty group wins)
    m = _AUDIO_OUTPUT_RE.search(q)
    if m:
        wanted = next((g for g in m.groups() if g), "").strip(" .,!?")
        if wanted:
            name = _switch_audio_output(wanted)
            if name:
                return f"Audio output switched to {name}, sir."
            return f"I couldn't find an output device matching {wanted}, sir."
    # 3) v1.34 honesty guard: the user clearly asked to switch where sound
    #    plays but nothing resolved — say so INSTEAD of falling through to the
    #    LLM, which would answer "Switched, sir." without doing anything.
    if _adm is not None and _adm.wants_output_switch(q):
        opts = ", ".join(f"'{p}'" for p in list(_AUDIO_COMMANDS)[:3])
        hint = f" Your saved phrases are {opts}." if opts else ""
        return ("I couldn't tell which output device you meant, sir." + hint)
    return None


# ── Whisper domain prompt — short prompt prevents hallucination (v1.3 fix) ──
# A long prompt of all app names caused Whisper to hallucinate those words into
# unrelated transcriptions.  Keep it to wake word + core command verbs only.
_WHISPER_DOMAIN_PROMPT = "Wivexx. Hey Wivexx. Open, close, volume, screenshot, timer, Discord, Spotify."

# ══════════════════════════════════════════════════════════════════════════════
#  APP CATEGORIES  (used by bulk-close operations)
# ══════════════════════════════════════════════════════════════════════════════
_APP_CATEGORIES: dict[str, list[str]] = {
    "browser":       ["chrome", "firefox", "edge", "brave", "opera"],
    "game":          ["steam", "epic games", "riot client", "battle.net",
                      "ea app", "ubisoft connect", "xbox", "gog galaxy",
                      "rockstar launcher", "discord",
                      "valorant", "league of legends", "cs2", "gta v",
                      "fortnite", "apex legends", "call of duty", "roblox"],
    "productivity":  ["word", "excel", "powerpoint", "outlook", "onenote",
                      "notepad++", "vs code", "notion", "obsidian", "trello",
                      "loom", "slack"],
    "media":         ["spotify", "vlc", "potplayer", "itunes", "foobar2000",
                      "winamp", "windows media player", "plex", "kodi"],
    "communication": ["discord", "slack", "teams", "zoom", "telegram", "skype",
                      "signal", "whatsapp", "viber"],
    "creative":      ["photoshop", "illustrator", "premiere pro", "after effects",
                      "audacity", "obs studio", "streamlabs", "davinci resolve",
                      "blender", "gimp", "inkscape", "handbrake"],
    "streaming":     ["obs studio", "streamlabs", "xsplit"],
    "music":         ["spotify", "vlc", "foobar2000", "itunes", "winamp", "potplayer"],
    "social":        ["discord", "slack", "teams", "telegram", "skype", "signal",
                      "whatsapp", "viber", "zoom"],
    "system":        ["task manager", "file explorer", "device manager",
                      "event viewer", "resource monitor", "registry editor",
                      "disk management"],
}

# Category aliases — maps spoken words → _APP_CATEGORIES key
_CATEGORY_ALIASES: dict[str, str] = {
    # game
    "game": "game", "games": "game", "gaming": "game",
    "videogame": "game", "video game": "game", "video games": "game",
    "videogames": "game",
    # browser
    "browser": "browser", "browsers": "browser",
    "navegator": "browser", "navigator": "browser",
    # productivity
    "productivity": "productivity", "work": "productivity", "office": "productivity",
    # media
    "media": "media", "entertainment": "media",
    # music
    "music": "music",
    # communication / social
    "communication": "communication", "messaging": "communication",
    "chat": "communication", "social": "communication",
    # creative
    "creative": "creative", "creativity": "creative", "design": "creative",
    # streaming
    "streaming": "streaming", "stream": "streaming",
    # system
    "system": "system", "tools": "system",
}


# ══════════════════════════════════════════════════════════════════════════════
#  APP CLOSER
# ══════════════════════════════════════════════════════════════════════════════
def _close_app(app_name: str) -> bool:
    """
    Kill a running app by spoken name (looks up exe in _APP_REGISTRY).
    Returns True if taskkill reports success, False otherwise.
    """
    name = app_name.lower().strip(" .,!?")

    entry = _APP_REGISTRY.get(name)
    if not entry:
        best_len = 0
        for variant, data in _APP_REGISTRY.items():
            if variant in name and len(variant) > best_len:
                entry = data
                best_len = len(variant)

    if not entry:
        # Auto-discovery fallback: resolve the app's exe from the Start Menu
        # index and taskkill it, so "close <app>" works for un-registered apps.
        try:
            import wivexx_appindex
            hit = wivexx_appindex.find_app(name)
            tgt = hit.get("target") if hit else ""
            if tgt:
                exe = os.path.basename(tgt)
                result = subprocess.run(
                    ["taskkill", "/F", "/IM", exe],
                    creationflags=subprocess.CREATE_NO_WINDOW,
                    capture_output=True, timeout=5,
                )
                if result.returncode == 0:
                    _logger.info("Closed app via index: %s (%s)", hit["name"], exe)
                    return True
        except Exception as _ci_e:
            _logger.debug("app index close failed: %s", _ci_e)
        return False

    display, exe, _paths = entry
    if exe.startswith("http") or exe.endswith(":"):
        return False  # can't taskkill a URL or protocol

    try:
        result = subprocess.run(
            ["taskkill", "/F", "/IM", exe],
            creationflags=subprocess.CREATE_NO_WINDOW,
            capture_output=True, timeout=5,
        )
        killed = result.returncode == 0
        if killed:
            _logger.info(f"Closed app: {display} ({exe})")
        return killed
    except Exception as e:
        _logger.debug(f"taskkill failed for {display}: {e}")
        return False


# ── Close-intent regex + detector ─────────────────────────────────────────────
_CLOSE_VERB_RE = re.compile(
    r'\b(?:close|quit|exit|shut\s+down|kill)\s+(.+)',
    re.IGNORECASE,
)

def _detect_close_intent(question: str) -> Optional[tuple]:
    """
    Detect a close/quit command.  Returns:
      ("all",      [])       — "close everything / close all apps"
      ("category", [cat])    — "close all browsers / close games / close gaming"
      ("apps",     [name…])  — "close Discord" / "close Discord and Spotify"
      None                   — no close intent detected

    Uses _CATEGORY_ALIASES so "close gaming", "close video games", etc. work.
    """
    q = _SPEECH_FILLER_RE.sub(" ", question.strip()).strip()
    m = _CLOSE_VERB_RE.search(q)
    if not m:
        return None

    candidate = _APP_TRAILING_RE.sub("", m.group(1)).strip(" .,!?")
    cl = candidate.lower()

    # "everything" / "all apps" / "all applications" / "all programs"
    if re.match(r'^(?:everything|all\s+(?:apps?|applications?|programs?))$', cl):
        return ("all", [])

    # Bare category alias — "close games", "close gaming", "close music", etc.
    bare_cat = _CATEGORY_ALIASES.get(cl)
    if bare_cat and bare_cat in _APP_CATEGORIES:
        return ("category", [bare_cat])

    # "all [my] [the] <category> [apps/games]"
    cat_m = re.match(
        r'^all\s+(?:my\s+)?(?:the\s+)?'
        r'([\w]+(?:[\s-][\w]+)?)(?:\s+(?:apps?|applications?|programs?|games?))?\s*$',
        cl,
    )
    if cat_m:
        cat_word = cat_m.group(1).strip()
        cat = _CATEGORY_ALIASES.get(cat_word)
        if cat and cat in _APP_CATEGORIES:
            return ("category", [cat])

    # Multi-app: "Discord and Spotify"
    if re.search(r'\s+and\s+', cl):
        parts = [p.strip() for p in re.split(r'\s+and\s+', candidate, flags=re.IGNORECASE) if p.strip()]
        return ("apps", parts)

    return ("apps", [candidate])


# v1.7: "what time does GTA update" / "what day does it release" are questions
# ABOUT a time, not requests FOR the time.  If the time/date words are attached
# to another subject, this guard rejects the local handler so the question
# falls through to Claude (with web search).
_TIME_SUBJECT_REJECT_RE = re.compile(
    r"\bwhat\s+(?:time|hour|day|date)\s+(?:does|do|did|will|would|should|can"
    r"|could|might|it|the|a\b|is\s+(?!it\b)|are|was|were)"
    r"|\b(?:time|hour|day|date)\b.{0,40}\b(?:update|updates|release|releases"
    r"|event|finish|finishes|start|starts|end|ends|come|comes|coming|drop"
    r"|drops|launch|launches|open|opens|close|closes|reset|resets|expire"
    r"|tomorrow|tonight|next\s+week)\b"
    r"|\b(?:update|event|release|reset)\b.{0,40}\b(?:time|hour)\b",
    re.IGNORECASE,
)

# Strict patterns that genuinely ask for the CURRENT time / date
_CURRENT_TIME_RE = re.compile(
    r"\bwhat(?:'s|\s+is)?\s+the\s+time\b"
    r"|\bwhat\s+time\s+is\s+it\b"
    r"|\bcurrent\s+time\b"
    r"|\btell\s+me\s+the\s+time\b"
    r"|\b(?:do\s+you\s+)?have\s+the\s+time\b"
    r"|\btime\s+(?:right\s+now|please)\b",
    re.IGNORECASE,
)
_CURRENT_DATE_RE = re.compile(
    r"\bwhat(?:'s|\s+is)?\s+(?:the\s+|today'?s\s+)?date\b"
    r"|\bwhat\s+day\s+is\s+(?:it|today)\b"
    r"|\bwhat\s+is\s+today\b"
    r"|\btoday'?s\s+date\b"
    r"|\bcurrent\s+date\b",
    re.IGNORECASE,
)


def _local_time_response(q: str) -> Optional[str]:
    q = q.lower()
    if _TIME_SUBJECT_REJECT_RE.search(q):
        return None     # asking about some event's time — not the clock
    if _CURRENT_DATE_RE.search(q):
        now = _dt.datetime.now()
        return f"Today is {now.strftime('%A, %B %d, %Y')}, sir."
    if _CURRENT_TIME_RE.search(q):
        now = _dt.datetime.now()
        return f"The time is {now.strftime('%I:%M %p')}, sir."
    return None


# ══════════════════════════════════════════════════════════════════════════════
#  TIMER / ALARM
# ══════════════════════════════════════════════════════════════════════════════
_WRITTEN_NUMBERS: dict[str, int] = {
    "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
    "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
    "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
    "twenty": 20, "thirty": 30, "forty": 40, "forty-five": 45, "fifty": 50,
    "sixty": 60, "ninety": 90,
}

# Expanded trigger — matches all timer/alarm/countdown/reminder phrasings
# including "in X minutes" without an explicit timer/alarm keyword.
_TIMER_TRIGGER_RE = re.compile(
    r'\b(?:'
    r'timer|alarm|reminder|remind\s+me|alert(?:\s+me)?|notify(?:\s+me)?'
    r'|wake\s+me|ping\s+me'
    r'|set\s+(?:an?\s+)?(?:timer|alarm|reminder)'
    r'|add\s+(?:an?\s+)?(?:timer|alarm)'
    r'|count(?:\s*down)|countdown'
    r')\b'
    r'|in\s+\d+\s*(?:sec(?:ond)?s?|min(?:ute)?s?|hours?)'
    r'|in\s+(?:one|two|three|four|five|six|seven|eight|nine|ten|fifteen'
    r'|twenty|thirty|forty|sixty|ninety|half|an?)\s+(?:sec(?:ond)?s?|min(?:ute)?s?|hours?)',
    re.IGNORECASE,
)


def _parse_timer_command(text: str) -> Optional[int]:
    """
    Parse a timer/alarm/reminder command.  Returns total seconds, or None.

    Trigger phrases (any match activates parsing):
      "set a timer for X" / "timer X" / "set timer X"
      "set an alarm for X" / "alarm in X" / "remind me in X" / "alert me in X"
      "wake me in X" / "ping me in X" / "notify me in X"
      "in X minutes/seconds/hours [do something]"
      "count down X" / "countdown X"

    Duration examples:
      "30 seconds" / "30 secs" / "30 sec" / "half a minute"
      "one minute" / "1 min" / "a minute"
      "two and a half minutes" / "2.5 minutes" / "2 minutes 30 seconds"
      "quarter hour" / "half hour" / "half an hour" / "an hour" / "one hour"
      "hour and a half" / "90 minutes" / "2 hours" / "3 hours 30 minutes"
    """
    t = text.lower().strip()

    if not _TIMER_TRIGGER_RE.search(t):
        return None

    total = 0
    found = False

    # ── "N and a half" compound (e.g. "two and a half minutes") ──────────────
    m = re.search(
        r'(\d+(?:\.\d+)?)\s+and\s+a\s+half\s+(hours?|min(?:utes?)?|sec(?:onds?)?)', t
    )
    if m:
        n    = float(m.group(1)) + 0.5
        unit = m.group(2).lower()
        if unit.startswith('h'):
            total += int(n * 3600)
        elif unit.startswith('m'):
            total += int(n * 60)
        else:
            total += int(n)
        found = True

    # ── Hours ─────────────────────────────────────────────────────────────────
    m = re.search(r'(\d+(?:\.\d+)?)\s*hours?\b', t)
    if m and not found:   # don't double-count after "and a half" match
        total += int(float(m.group(1)) * 3600)
        found = True
    elif not found:
        for word, num in _WRITTEN_NUMBERS.items():
            if re.search(rf'\b{re.escape(word)}\s+hours?\b', t):
                total += num * 3600
                found = True
                break

    # "an hour" / "one hour" (already caught by written-number loop for "one")
    if re.search(r'\ban\s+hour\b', t) and 'hour' not in t[:t.index('an hour')].replace('an hour', ''):
        # only add if hours haven't been counted yet (avoid double)
        pass   # "an" → handled below via the written-number loop catching "one"

    # "hour and a half" (standalone phrase — 1.5 h = 5400 s)
    if re.search(r'\bhour\s+and\s+a\s+half\b', t):
        # already handled above if digits present; catch plain phrase
        if not found:
            total += 5400
            found = True

    # "half [an] hour"
    if re.search(r'\bhalf(?:\s+an?)?\s+hour\b', t):
        total += 1800
        found = True

    # "quarter [of an] hour"
    if re.search(r'\bquarter(?:\s+(?:of\s+an?\s+)?)?hour\b', t):
        total += 900
        found = True

    # ── Minutes ───────────────────────────────────────────────────────────────
    m = re.search(r'(\d+(?:\.\d+)?)\s*min(?:utes?|s)?\b', t)
    if m:
        total += int(float(m.group(1)) * 60)
        found = True
    else:
        for word, num in _WRITTEN_NUMBERS.items():
            if re.search(rf'\b{re.escape(word)}\s+min(?:utes?|s)?\b', t):
                total += num * 60
                found = True
                break

    # "a minute" / "an minute" (indefinite article, singular)
    if re.search(r'\ba\s+minute\b', t) and not re.search(r'\d+\s*min', t):
        total += 60
        found = True

    # "half a minute" / "half minute"
    if re.search(r'\bhalf\s+(?:a\s+)?minute\b', t):
        total += 30
        found = True

    # ── Seconds ───────────────────────────────────────────────────────────────
    m = re.search(r'(\d+)\s*sec(?:onds?|s)?\b', t)
    if m:
        total += int(m.group(1))
        found = True
    else:
        for word, num in _WRITTEN_NUMBERS.items():
            if re.search(rf'\b{re.escape(word)}\s+sec(?:onds?|s)?\b', t):
                total += num
                found = True
                break

    return total if (found and total > 0) else None


def _fmt_duration(seconds: int) -> str:
    """Format seconds into a spoken string: '1 hour and 30 minutes', '5 minutes', etc."""
    h, rem = divmod(seconds, 3600)
    m, s   = divmod(rem, 60)
    parts: list[str] = []
    if h:
        parts.append(f"{h} hour{'s' if h != 1 else ''}")
    if m:
        parts.append(f"{m} minute{'s' if m != 1 else ''}")
    if s and not h:            # skip seconds when hours are involved
        parts.append(f"{s} second{'s' if s != 1 else ''}")
    if not parts:
        parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
    return " and ".join(parts)


# ── Active task registry (v1.8.1) — timers + scheduled shutdown ───────────────
# So "what's pending, sir?" can report what WIVEXX is actually tracking.
_active_timers: list[dict] = []          # {"id", "label", "ends": epoch}
_active_timers_lock = threading.Lock()
_timer_id_counter = 0
_timer_cancels: dict = {}                 # tid -> threading.Event (cancellable)


def _register_timer(seconds: int, label: str) -> int:
    global _timer_id_counter
    with _active_timers_lock:
        _timer_id_counter += 1
        tid = _timer_id_counter
        _active_timers.append({"id": tid, "label": label,
                               "ends": time.time() + seconds})
    return tid


def _unregister_timer(tid: int) -> None:
    with _active_timers_lock:
        _active_timers[:] = [t for t in _active_timers if t["id"] != tid]


def _list_active_timers() -> list[dict]:
    now = time.time()
    with _active_timers_lock:
        return [dict(t) for t in _active_timers if t["ends"] > now]


def _cancel_timer(tid) -> bool:
    """Cancel a running timer by id (countdown overlay × button, or voice)."""
    try:
        tid = int(tid)
    except (TypeError, ValueError):
        return False
    with _active_timers_lock:
        ev = _timer_cancels.get(tid)
    if ev is not None:
        ev.set()
        return True
    return False


def _start_timer(seconds: int, label: str = "") -> None:
    """Run a timer in-process.  On fire it shows the web completion overlay (or,
    with no web GUI, the detached _timer_alert.py popup that survives Wivexx
    closing) and queues a spoken heads-up.  Cancellable via _cancel_timer so the
    live-countdown overlay can stop it."""
    if not label:
        label = _fmt_duration(seconds)
    _logger.info(f"Timer started: {seconds}s ({label!r})")
    tid = _register_timer(seconds, label)
    cancel = threading.Event()
    with _active_timers_lock:
        _timer_cancels[tid] = cancel

    def _run() -> None:
        end = time.time() + seconds
        while True:
            remaining = end - time.time()
            if remaining <= 0:
                break
            if cancel.wait(min(0.25, remaining)):     # interruptible sleep
                break
        _unregister_timer(tid)
        with _active_timers_lock:
            _timer_cancels.pop(tid, None)
        if cancel.is_set():
            _logger.info(f"Timer cancelled: {label!r}")
            return
        # fire: prefer the in-app web overlay; fall back to the detached popup
        handled = False
        try:
            import wivexx_gui
            handled = bool(wivexx_gui.fire_timer_alert(label, seconds))
        except Exception:
            handled = False
        if not handled:
            try:
                subprocess.Popen(
                    [sys.executable, _TIMER_ALERT_SCRIPT, str(seconds), label],
                    creationflags=(subprocess.DETACHED_PROCESS
                                   | subprocess.CREATE_NEW_PROCESS_GROUP
                                   | subprocess.CREATE_NO_WINDOW),
                    close_fds=True, stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
            except Exception as e:
                _logger.warning(f"_start_timer: alert fallback failed ({e})")
        _timer_done_queue.put(label)
        _logger.info(f"Timer fired: {label!r}")

    threading.Thread(target=_run, daemon=True, name="wivexx-timer").start()


# ══════════════════════════════════════════════════════════════════════════════
#  SYSTEM SHUTDOWN (v1.8.1) — voice/web, optional delay, confirmation, cancel
# ══════════════════════════════════════════════════════════════════════════════
_shutdown_state: dict = {"armed": False, "ends": 0.0, "label": ""}
_shutdown_lock = threading.Lock()
# A shutdown awaiting a TYPED/remote "confirm" — remembers the requested delay so
# "confirm shutdown" alone keeps the original "in 30 minutes" (expires after 5 min).
_pending_shutdown: dict = {"delay": 0, "ts": 0.0}

_SHUTDOWN_RE = re.compile(
    r'\b(?:shut\s*down|shutdown|turn\s*off|power\s*off|power\s*down)\b'
    r'.{0,30}\b(?:pc|computer|machine|system|rig|desktop)\b'
    r'|\b(?:shut\s*down|shutdown|turn\s*off|power\s*off)\s+(?:my\s+|the\s+|this\s+)?'
    r'(?:pc|computer|machine|system|rig|desktop)\b',
    re.IGNORECASE,
)
_SHUTDOWN_CANCEL_RE = re.compile(
    r'\b(?:cancel|stop|abort|never\s*mind|nevermind|call\s+off|don\'?t)\b'
    r'.{0,30}\bshut\s*down|\bcancel\s+(?:the\s+)?shutdown\b'
    r'|\b(?:stop|abort)\s+(?:the\s+)?shutdown\b',
    re.IGNORECASE,
)
# Lock the workstation — "lock the pc", "lock my computer", "lock the screen".
# Requires an explicit device noun so "lock the door", "o'clock", "lockdown" and
# "lock it in" never trigger a real screen lock. (v1.43: the inline set had a
# 'lock the pc' pattern with NO handler — it fell to the brain, which faked it.)
_LOCK_RE = re.compile(
    r'\b(?:lock\s+(?:up\s+)?|secure\s+)(?:my\s+|the\s+|this\s+)?'   # v1.53: + "secure my pc"
    r'(?:pc|computer|laptop|screen|workstation|desktop|machine|session)\b',
    re.IGNORECASE,
)
# Sleep / suspend / hibernate the workstation (v1.53). Like _LOCK_RE, it REQUIRES an
# explicit device noun (or the unambiguous word "hibernate") so ordinary talk —
# "I'm going to sleep", "can't sleep", "go to sleep", "let's sleep on it" — never
# suspends the PC. Sleep is reversible, so (unlike shutdown) no voice confirmation.
_SLEEP_RE = re.compile(
    r'\b(?:put|send|set)\s+(?:my\s+|the\s+|this\s+)?'
    r'(?:pc|computer|laptop|machine|desktop|system|rig|screen)\s+(?:in)?to\s+sleep\b'
    r'|\b(?:sleep|suspend)\s+(?:my\s+|the\s+|this\s+)?'
    r'(?:pc|computer|laptop|machine|desktop|system|rig)\b'
    r'|\bhibernate\b(?:\s+(?:my\s+|the\s+|this\s+)?'
    r'(?:pc|computer|laptop|machine|desktop|system|rig))?',
    re.IGNORECASE,
)
# A shutdown verb + a TIME ("in/after N …") or "now", WITHOUT a device word — e.g.
# "shutdown in 30 minutes". Without this, that phrase has no "pc"/"computer" so it
# missed _SHUTDOWN_RE and fell through to the timer intent → "Timer set…" (the bug).
# "turn off" is excluded here (too ambiguous — "turn off the music in 5 min").
_SHUTDOWN_TIME_RE = re.compile(
    r'\b(?:shut\s*down|shutdown|power\s*off|power\s*down)\b.{0,30}\b(?:in|after)\b\s+\d'
    r'|\b(?:shut\s*down|shutdown|power\s*off|power\s*down)\b\s+now\b',
    re.IGNORECASE,
)


def _notify_web(kind: str, title: str, **kw) -> None:
    """Best-effort: push an activity notification + bell to the web control room."""
    try:
        import wivexx_gui as _g
        web = getattr(_g, "_web", None)
        if web:
            web._add_notif(kind, title, **kw)
    except Exception:
        pass


def _refresh_web_activity() -> None:
    """Best-effort: nudge the web activity tab to re-pull (e.g. the live countdown)."""
    try:
        import wivexx_gui as _g
        web = getattr(_g, "_web", None)
        if web:
            web.js("activityChanged")
    except Exception:
        pass


def _shutdown_info() -> Optional[dict]:
    """The armed shutdown (for the activity-tab countdown), or None."""
    with _shutdown_lock:
        if not _shutdown_state["armed"]:
            return None
        return {"armed": True, "ends": _shutdown_state["ends"], "label": _shutdown_state["label"]}


def _parse_shutdown_delay(text: str) -> Optional[int]:
    """Seconds of delay for a shutdown command, or 0 for immediate.
    Reuses the timer duration parser for 'in 30 minutes' etc."""
    if re.search(r'\b(?:in|after)\b', text, re.IGNORECASE):
        secs = _parse_timer_command(text)
        if secs:
            return secs
    return 0


def _cancel_shutdown() -> bool:
    with _shutdown_lock:
        if not _shutdown_state["armed"]:
            return False
        _shutdown_state.update({"armed": False, "ends": 0.0, "label": ""})
    try:
        subprocess.run(["shutdown", "/a"], creationflags=subprocess.CREATE_NO_WINDOW,
                       capture_output=True, timeout=5)
    except Exception as e:
        _logger.warning("shutdown abort failed: %s", e)
    _logger.info("Scheduled shutdown cancelled.")
    _notify_web("shutdown", "Shutdown cancelled", sub="the scheduled shutdown was called off", status="done")
    _refresh_web_activity()
    return True


def _arm_shutdown(delay_secs: int) -> str:
    """Schedule (or immediately trigger) a Windows shutdown. Returns spoken msg."""
    delay_secs = max(0, int(delay_secs))
    # Windows shutdown timer is itself reliable; /t sets the delay.
    with _shutdown_lock:
        _shutdown_state.update({
            "armed": True,
            "ends": time.time() + delay_secs,
            "label": _fmt_duration(delay_secs) if delay_secs else "now",
        })
    try:
        subprocess.run(["shutdown", "/s", "/t", str(delay_secs),
                        "/c", "Wivexx shutting the system down, sir."],
                       creationflags=subprocess.CREATE_NO_WINDOW,
                       capture_output=True, timeout=5)
    except Exception as e:
        _logger.error("shutdown failed: %s", e)
        with _shutdown_lock:
            _shutdown_state.update({"armed": False, "ends": 0.0, "label": ""})
        return "I couldn't issue the shutdown command, sir."
    _logger.info("Shutdown armed in %ds", delay_secs)
    # a DANGER notification (bumps the bell) + the live cancellable countdown shows in
    # the activity tab via _shutdown_info → _active_jobs
    _notify_web("shutdown", "Shutdown scheduled",
                sub=("the PC will shut down in " + _fmt_duration(delay_secs) + " — cancel from the activity tab")
                    if delay_secs else "shutting down now",
                status="danger")
    _refresh_web_activity()
    if delay_secs == 0:
        return "Shutting down the system now, sir. Goodbye."
    return (f"Understood, sir. The system will shut down in "
            f"{_fmt_duration(delay_secs)}. Say 'cancel shutdown' to stop it.")


def _shutdown_status() -> Optional[str]:
    with _shutdown_lock:
        if not _shutdown_state["armed"]:
            return None
        remaining = int(_shutdown_state["ends"] - time.time())
    if remaining <= 0:
        return "a system shutdown is imminent"
    return f"a system shutdown in {_fmt_duration(remaining)}"


def _list_pending() -> str:
    """Plain-language list of everything WIVEXX is currently tracking."""
    parts: list[str] = []
    now = time.time()
    for t in _list_active_timers():
        remaining = int(t["ends"] - now)
        if remaining > 0:
            parts.append(f"a {t['label']} timer with {_fmt_duration(remaining)} left")
    sd = _shutdown_status()
    if sd:
        parts.append(sd)
    try:
        import wivexx_media_import as _mi
        st = _mi.get_import_status()
        if st.get("active"):
            parts.append(f"a media import from {st.get('label', 'a drive')} "
                         f"({st.get('copied', 0)} files copied so far)")
    except Exception:
        pass
    try:
        import wivexx_disk as _jd
        if _jd.analysis_status().get("active"):
            parts.append("a disk analysis in progress")
    except Exception:
        pass
    try:
        import wivexx_goals as _jg
        today = _dt.date.today().isoformat()
        due = [g for g in _jg.list_goals()
               if g.get("due") and g["due"] <= today]
        if due:
            parts.append(f"{len(due)} goal{'s' if len(due) != 1 else ''} "
                         f"due today or overdue")
    except Exception:
        pass
    try:
        import wivexx_workmode as _jw
        if _jw.is_enabled():
            parts.append("work mode active (auto-extracting Downloads)")
    except Exception:
        pass
    try:
        import wivexx_socials as _js
        n_trk = len(_js.list_trackers())
        if n_trk:
            parts.append(f"{n_trk} social media tracker"
                         f"{'s' if n_trk != 1 else ''} watching growth")
    except Exception:
        pass
    if not parts:
        return "Nothing is pending at the moment, sir. All tasks are clear."
    if len(parts) == 1:
        return f"You have {parts[0]}, sir."
    return ("Here is what's pending, sir: "
            + "; ".join(parts[:-1]) + "; and " + parts[-1] + ".")


# ── Work mode voice toggle (v1.11) ────────────────────────────────────────────
_WORK_MODE_ON_RE = re.compile(
    r"\b(?:enable|activate|turn\s+on|start|engage|enter|begin|switch\s+to|"
    r"go\s+into)\s+(?:the\s+)?work\s+mode\b"
    r"|\bwork\s+mode\s+on\b", re.IGNORECASE)
_WORK_MODE_OFF_RE = re.compile(
    r"\b(?:disable|deactivate|turn\s+off|stop|disengage|end|exit|leave|"
    r"quit)\s+(?:the\s+)?work\s+mode\b"
    r"|\bwork\s+mode\s+off\b",
    re.IGNORECASE)


def _research_goal(title: str) -> None:
    """🔎 on a goal: WIVEXX web-searches practical guidance for that task and
    speaks it (also lands in the chat feed / phone)."""
    question = (f"Search for practical tips: give me a short, actionable plan "
                f"to accomplish this task: {title}")
    threading.Thread(target=_handle_turn, args=(question,),
                     daemon=True, name="wivexx-goal-research").start()


# ── Caption studio (v1.12 / v1.13): research-backed captions + hashtags ──────
# The rules every professional caption follows, distilled from current
# platform guidance — the model is held to these explicitly.
_CAPTION_PRO_RULES = """\
NON-NEGOTIABLE PROFESSIONAL RULES (apply to every option):
1. GROUND IT IN THE CONTENT. If frames of the post are attached, describe what
   they actually show and write captions about THAT — never generic filler.
2. HOOK FIRST. Line 1 must stop the scroll on its own and land BEFORE the
   "…more" cut — aim for under ~80 characters. Use curiosity, a bold specific
   claim, a number, or emotion — never bait the content can't pay off.
3. KEYWORD SEO + SEMANTIC. Captions are search-indexed (and Google-indexed for
   pro accounts). Put the exact phrase people would search in the FIRST
   sentence, then naturally use related / synonym terms through the body so the
   algorithm maps the topic. Never keyword-stuff.
4. SCANNABLE. One idea per line, short lines, deliberate line breaks.
5. VOICE-MATCHED. If a creator profile is provided (bio, past captions),
   write in THEIR voice — vocabulary, energy, sentence length.
6. ONE CTA — bias to SAVES & SHARES (the strongest discovery signals): e.g.
   "save this for later", "send this to a friend who…", or one easy-to-answer
   question. Exactly one CTA, never stacked.
7. EMOJIS: 0-3 maximum, as visual anchors only.
8. HASHTAGS — Instagram: the FINAL set is 3-5 tags MAX (the platform now hard-
   limits 5); make them NICHE/community tags in the ~10K-500K post band (where
   the post can actually rank), at most ONE broader tag; never banned or
   engagement-bait tags (#follow4follow, #likeforlike…). Also offer a few ALT
   niche/mid tags to swap in. TikTok: 3-6 tags; one trending + the rest niche
   keywords (TikTok discovery is search-driven, so keywords beat tag volume).
9. Multi-word hashtags in CamelCase for accessibility (#SunsetPhotography).
10. If both platforms are requested, hashtag sets MUST differ — pick per
    platform, do not copy one list to the other.
11. CAROUSEL: if multiple slides are attached, write ONE caption for the whole
    set as a mini-story, and make slide 1's idea the hook."""


def _caption_generate(payload: dict) -> dict:
    """The caption engine behind the GUI Caption Studio and the phone command.
    payload keys (all optional except topic-or-frames):
      topic, platforms=["instagram","tiktok"], client_name, handle,
      frames_b64=[jpeg-b64,…], file_name, media_kind, feedback, previous
    Returns {"ok": bool, "text": str, "error": str}."""
    topic = (payload.get("topic") or "").strip()
    platforms = [p for p in (payload.get("platforms") or ["instagram"])
                 if p in ("instagram", "tiktok")] or ["instagram"]
    frames = list(payload.get("frames_b64") or [])[:10]
    feedback = (payload.get("feedback") or "").strip()
    previous = (payload.get("previous") or "").strip()
    handle = (payload.get("handle") or "").strip().lstrip("@")
    client = (payload.get("client_name") or "").strip()
    n_images = int(payload.get("n_images") or 0)
    is_carousel = (payload.get("media_kind") == "carousel") or n_images > 1

    if not topic and not frames:
        return {"ok": False, "text": "",
                "error": "Give me the topic or attach the post itself, sir."}

    # ── creator profile context (cached snapshots — never hammers IG) ────────
    profile_txt = ""
    if handle:
        _gui_busy("caption", f"Analysing @{handle}'s profile…")
        try:
            import wivexx_socials
            ctx = wivexx_socials.profile_context(handle)
            if ctx:
                samples = "\n".join(f"  - “{c}”"
                                    for c in ctx["caption_samples"]) or "  (none)"
                profile_txt = (
                    f"CREATOR PROFILE (@{ctx['handle']}"
                    + (f", {ctx['full_name']}" if ctx.get("full_name") else "")
                    + f"):\n- Bio: {ctx['bio'] or '(none)'}\n"
                    f"- Followers: {ctx['followers']:,}"
                    + (f" · engagement {ctx['engagement_rate']}%"
                       if ctx.get("engagement_rate") is not None else "")
                    + f"\n- Recent caption style samples:\n{samples}\n")
            else:
                profile_txt = (f"CREATOR PROFILE: @{handle} (Instagram "
                               f"unreachable right now — match a clean, "
                               f"professional creator voice).\n")
        except Exception as _pe:
            _logger.debug("caption profile context failed: %s", _pe)
    if client and not profile_txt:
        profile_txt = f"CREATOR: {client}.\n"
    elif client:
        profile_txt = f"CREATOR NAME: {client}.\n" + profile_txt

    # ── live hashtag research, per platform ──────────────────────────────────
    research_txt = ""
    if not feedback:                      # revisions reuse the previous pack
        for plat in platforms:
            _gui_busy("caption", f"Researching {plat} hashtags…")
            try:
                r = _search_web(
                    f"best {plat} hashtags for {topic or 'this content'} "
                    f"high engagement", max_results=4) or ""
                if r:
                    research_txt += (f"\nLIVE WEB RESEARCH — {plat.upper()} "
                                     f"hashtags for this topic:\n{r}\n")
            except Exception as _re_e:
                _logger.debug("caption research (%s) failed: %s", plat, _re_e)

    # ── build the prompt ──────────────────────────────────────────────────────
    plat_names = " + ".join(p.capitalize() for p in platforms)
    hashtag_sections = "\n".join(
        (f"{p.upper()} HASHTAGS: <the FINAL set to paste — "
         + ("EXACTLY 3-5 niche tags in the 10K-500K post band"
            if p == "instagram" else "3-6 tags: one trending + niche keywords")
         + ", space-separated, #CamelCase>\n"
         f"{p.upper()} ALT TAGS: <4-6 more niche/mid tags to swap in>")
        for p in platforms)
    if feedback and previous:
        head = (f"Revise the social media captions below per the creator's "
                f"feedback. Produce 3 NEW options that apply the feedback "
                f"while keeping everything that already worked. Keep the "
                f"hashtag sets unless the feedback asks to change them.\n\n"
                f"PREVIOUS PACK:\n{previous[:4000]}\n\n"
                f"FEEDBACK: {feedback}\n\n")
    else:
        head = (f"Create a caption pack for a {plat_names} post.\n"
                f"POST TOPIC: {topic or 'see the attached frames'}\n"
                + (f"FILE: {payload.get('file_name', '')} "
                   f"({payload.get('media_kind', '')})\n"
                   if payload.get("file_name") else ""))
        if is_carousel:
            head += (f"FORMAT: This is an INSTAGRAM CAROUSEL of {n_images} images, "
                     f"attached IN ORDER (slide 1 first). Read the WHOLE set and "
                     f"write ONE caption for the carousel — a mini-story across the "
                     f"slides, with slide 1's idea as the hook.\n")
    prompt = (
        "Act as a senior social media strategist who writes captions for a "
        "living.\n\n" + head
        + (profile_txt + "\n" if profile_txt else "")
        + (research_txt + "\n" if research_txt else "")
        + _CAPTION_PRO_RULES + "\n\n"
        "Output EXACTLY this structure, no preamble, no markdown headers:\n"
        "OPTION 1 — short & punchy:\n<caption>\n\n"
        "OPTION 2 — storytelling:\n<caption>\n\n"
        "OPTION 3 — value/educational:\n<caption>\n\n"
        + hashtag_sections + "\n"
        "WHY THESE TAGS: <1-2 lines: the niche/mid mix + why they fit THIS content>\n"
        + ("CAROUSEL TIPS: <a slide-1 hook line to put ON the first image + where to "
           "add a swipe cue + one tip to keep viewers swiping to slide 3>\n"
           if is_carousel else "")
        + "POSTING TIP: <best posting window + one tactical tip for this content>\n"
        "Keep the whole answer under 420 words."
    )

    blocks = [{"type": "image",
               "source": {"type": "base64", "media_type": "image/jpeg",
                          "data": f}} for f in frames]
    _gui_busy("caption", "Drafting captions…")
    _bg_busy_inc()            # web-panel caption runs outside the turn lock
    set_tray("processing", "Wivexx — drafting captions")
    try:
        full = _sanitize_response(
            _ask_claude(prompt, extra_blocks=blocks or None, max_tokens=1400))
        return {"ok": True, "text": full, "error": ""}
    except anthropic.APIConnectionError:
        return {"ok": False, "text": "",
                "error": "No connection to the API, sir."}
    except Exception as _ce:
        _logger.error("caption draft failed: %s", _ce)
        return {"ok": False, "text": "",
                "error": "The caption engine ran into a problem, sir."}
    finally:
        _gui_busy("caption", None)
        _bg_busy_dec()
        set_tray("standby", "Wivexx — standby")


def _cmd_social_caption(question: str, topic: str) -> None:
    """Phone/web fallback: inline caption pack in the chat (the PC opens the
    Caption Studio window instead — see the intent handler)."""
    topic = (topic or "").strip(" .,!?\"'")
    if not topic:
        speak("What is the post about, sir? Say, for example, 'create a "
              "caption for my sunset drone video'.")
        return
    platforms = ["instagram"]
    if "tiktok" in question.lower():
        platforms = (["instagram", "tiktok"]
                     if re.search(r"\b(?:and|&|\+|both)\b|instagram|\big\b",
                                  question.lower())
                     else ["tiktok"])
    res = _caption_generate({"topic": topic, "platforms": platforms})
    _log_turn("user", question)
    if not res["ok"]:
        speak(res["error"])
        return
    _log_turn("assistant", res["text"])
    speak(res["text"])                   # remote turns capture → browser chat


# ── Memory voice commands (v1.10) — ChatGPT-memory-style user control ────────
_REMEMBER_RE = re.compile(
    r"\b(?:remember|memori[sz]e|don'?t\s+forget)\s+(?:that\s+|this[:,]\s*)(.+)$"
    r"|\bremember\s+(my\s+.+)$", re.IGNORECASE)
_FORGET_RE = re.compile(
    r"\b(?:forget|delete|remove|erase)\s+"
    r"(?:the\s+|that\s+|everything\s+)?(?:memory|memories|fact|facts)?\s*"
    r"(?:about|regarding|of)\s+(.+)$"
    r"|\bforget\s+what\s+i\s+(?:told|said\s+to)\s+you\s+about\s+(.+)$"
    r"|\bforget\s+(my\s+.+)$", re.IGNORECASE)
_WHAT_REMEMBER_RE = re.compile(
    r"\bwhat\s+do\s+you\s+remember\s+about\s+(.+)$"
    r"|\bwhat\s+do\s+you\s+know\s+about\s+(me|myself)\b", re.IGNORECASE)


def _derive_memory_key(statement: str) -> str:
    """'my gaming account is vegeta777' → 'gaming account'."""
    head = re.split(r"\b(?:is|are|was|were|=)\b", statement, maxsplit=1)[0]
    head = re.sub(r"\b(?:my|the|a|an|that|i)\b", " ", head, flags=re.IGNORECASE)
    head = re.sub(r"\s+", " ", head).strip(" .,:")
    if not head:
        head = " ".join(statement.split()[:4])
    return head.lower()[:50]


# ── Goals / to-do voice commands (v1.9) ───────────────────────────────────────
_GOALS_QUERY_RE = re.compile(
    r"\b(?:what(?:'?s|\s+are)\s+(?:my\s+)?goals?\b"
    r"|show\s+(?:me\s+)?(?:my\s+)?goals?\b"
    r"|my\s+(?:goals?|to[\s-]?do(?:\s+list)?)\b"
    r"|goals?\s+list|today'?s\s+focus"
    r"|what\s+(?:do|should)\s+i\s+(?:need\s+to\s+)?do\s+today)\b",
    re.IGNORECASE,
)
_ADD_GOAL_RE = re.compile(
    r"\badd\s+(?:a\s+)?(?:new\s+)?goal[:,]?\s+(.+)$", re.IGNORECASE)
_COMPLETE_GOAL_RE = re.compile(
    r"\b(?:complete|finish|check\s+off|mark\s+off|i\s+(?:finished|completed|did))"
    r"\s+(?:the\s+)?goal[:,]?\s+(.+)$"
    # v1.34: ordinal forms — "complete my first goal", "finish the 2nd goal"
    r"|\b(?:complete|finish|check\s+off|mark\s+off)\s+(?:my\s+|the\s+)?"
    r"(first|second|third|fourth|fifth|last|latest|newest|oldest|\d+(?:st|nd|rd|th)?)\s+goal\b"
    # "mark my goal as done" (only makes sense with a single open goal)
    r"|\bmark\s+(?:my\s+|the\s+)?goal\s+(?:as\s+)?(?:done|complete|completed|finished)\b",
    re.IGNORECASE)


# ── Social media trackers + caption studio (v1.12) ───────────────────────────
_TRACK_SOCIAL_RE = re.compile(
    r"\b(?:track|start\s+tracking|add\s+(?:a\s+)?(?:social(?:\s+media)?\s+)?tracker\s+for)\s+"
    r"(?:my\s+)?(?:instagram|insta|ig)\s+(?:account\s+|profile\s+)?@?([A-Za-z0-9._]{2,30})\b"
    r"|\btrack\s+@?([A-Za-z0-9._]{2,30})\s+on\s+(?:instagram|insta|ig)\b",
    re.IGNORECASE,
)
_UNTRACK_SOCIAL_RE = re.compile(
    r"\b(?:stop\s+tracking|untrack|remove\s+(?:the\s+)?(?:social(?:\s+media)?\s+)?tracker"
    r"(?:\s+for)?)\s+(?:my\s+)?(?:instagram\s+|insta\s+|ig\s+)?(?:account\s+)?"
    r"@?([A-Za-z0-9._]{2,30})\b",
    re.IGNORECASE,
)
_SOCIAL_REPORT_RE = re.compile(
    r"\bhow(?:'s|\s+is|\s+are)\s+(?:my\s+)?(?:instagram|insta|ig|socials?(?:\s+media)?)"
    r"(?:\s+account)?\s*(?:doing|going|growing|performing)?\b"
    r"|\b(?:social(?:\s+media)?|instagram|insta|ig)\s+"
    r"(?:report|briefing|update|stats|statistics|growth|summary)\b"
    r"|\bhow\s+did\s+my\s+(?:last|latest|newest|recent)\s+"
    r"(?:video|post|reel)\s+(?:do|perform|go)\b"
    r"|\bhow\s+(?:is|was)\s+my\s+(?:last|latest|newest)\s+(?:video|post|reel)\b",
    re.IGNORECASE,
)
_CAPTION_RE = re.compile(
    r"\b(?:create|write|generate|make|draft|give\s+me|build)\s+"
    r"(?:me\s+)?(?:an?\s+|some\s+)?"
    r"(?:(?:instagram|insta|ig|tiktok|social(?:\s+media)?)\s+)?"
    r"(?:caption|captions)\b(?:\s+(?:for|about|on)\s+(.+))?"
    r"|\bcaption\s+(?:this|the|my)\s+(.+)$"          # v1.34: "caption this video"
    r"|\b(?:best\s+)?hashtags?\s+for\s+(.+)$",
    re.IGNORECASE,
)
# v1.14: client photo delivery — develop ARW→JPG + upload to Drive + share link
_DELIVER_RE = re.compile(
    r"\b(?:deliver|send|share)\s+(?:these\s+|the\s+|my\s+|this\s+)?"
    r"(?:photos?|pictures?|pics?|images?|shoot|gallery|folder)\b"
    r".{0,30}\b(?:to\s+(?:a\s+|my\s+|the\s+)?client|to\s+(?:google\s+)?drive|"
    r"to\s+(?:a\s+)?client)\b"
    r"|\b(?:upload|send)\s+(?:the\s+|this\s+|a\s+)?(?:photos?|folder|gallery|"
    r"shoot)\s+to\s+(?:google\s+)?drive\b"
    r"|\b(?:convert|develop|process|transform)\s+(?:my\s+|the\s+|these\s+)?"
    r"(?:arw|raw|sony)\s*(?:photos?|files?|images?)?\s*(?:to|into)\s+jpe?gs?\b"
    r"|\b(?:client\s+delivery|deliver\s+(?:a\s+)?(?:shoot|gallery|photos?))\b"
    r"|\b(?:make|create|build)\s+(?:a\s+)?(?:client\s+)?gallery\b",
    re.IGNORECASE,
)

# ── v1.15: quick local tools — calculator/converter, clipboard, window control ─
try:
    import wivexx_calc as _calc_mod
except Exception:
    _calc_mod = None

_CLIP_RE = re.compile(
    r"\b(?:clip\s?board|what\s+i\s+(?:just\s+)?copied|copied\s+text|"
    r"what\s+i'?ve\s+(?:just\s+)?copied|the\s+thing\s+i\s+copied)\b",
    re.IGNORECASE,
)
_WIN_MOVE_RE = re.compile(
    r"\b(?:move|send|put|throw|shift|drag)\s+(?:the\s+)?(.+?)\s+(?:window\s+)?"
    r"(?:to|onto|on\s+to|on|over\s+to)\s+(?:the\s+|my\s+)?"
    r"(left|right|primary|main|secondary|second|first|1st|2nd|3rd|third|"
    r"other|next)\s+(?:monitor|screen|display)\b",
    re.IGNORECASE,
)
_WIN_MINALL_RE = re.compile(
    r"\bminimi[sz]e\s+(everything|all(?:\s+windows?)?|every\s+window|the\s+rest)"
    r"(?:\s+(?:except|but|apart\s+from|other\s+than)\s+(?:for\s+)?(?:the\s+)?(.+))?$",
    re.IGNORECASE,
)
_WIN_ACT_RE = re.compile(
    r"\b(minimi[sz]e|maximi[sz]e|restore|un-?minimi[sz]e|focus(?:\s+on)?|"
    r"bring(?:\s+up)?|switch\s+to)\s+(?:the\s+|my\s+|on\s+)?(.+?)"
    r"(?:\s+(?:window|app|to\s+the\s+front|to\s+front|please))?\s*$",
    re.IGNORECASE,
)
_WINCTL_GATE_RE = re.compile(
    r"\b(monitor|screen|display|minimi[sz]e|maximi[sz]e|restore|"
    r"focus|bring|switch\s+to)\b",
    re.IGNORECASE,
)


# tool-style system prompt shared by the Claude + local-brain clipboard transforms
_CLIP_TOOL_SYS = ("You are a precise text tool. Output ONLY the requested "
                  "result — no preamble, no quotes, no commentary.")


def _clip_llm_local(instruction: str, content: str) -> str:
    """v1.38: run a clipboard transform on the local Ollama brain (no API key)."""
    model = _ollama_chat_model()
    if not model:
        return ""
    try:
        body = json.dumps({"model": model, "stream": False, "keep_alive": "30m",
            "options": {"temperature": 0.2, "num_predict": 700},
            "messages": [{"role": "system", "content": _CLIP_TOOL_SYS},
                         {"role": "user", "content": f"{instruction}\n\n---\n{content[:8000]}"}]}).encode()
        with urllib.request.urlopen(urllib.request.Request(
                f"{_OLLAMA_BASE_URL}/api/chat", data=body,
                headers={"Content-Type": "application/json"}), timeout=60) as r:
            d = json.loads(r.read().decode("utf-8"))
        return (d.get("message", {}) or {}).get("content", "").strip()
    except Exception as _e:
        _logger.warning("clipboard local-brain call failed: %s", _e)
        return ""


def _clip_llm(instruction: str, content: str, max_tokens: int = 700) -> str:
    """One-off LLM call that returns ONLY the transformed text (no persona,
    no conversation history) — suitable for writing straight back to the
    clipboard.  Uses Claude when a key is set, else the local Ollama brain."""
    if not _HAS_API:
        return _clip_llm_local(instruction, content) or NO_API_MSG
    try:
        resp = _client.messages.create(
            model=cfg.MODEL, max_tokens=max_tokens, system=_CLIP_TOOL_SYS,
            messages=[{"role": "user",
                       "content": f"{instruction}\n\n---\n{content[:12000]}"}])
        try:
            _record_usage(getattr(resp.usage, "input_tokens", 0),
                          getattr(resp.usage, "output_tokens", 0))
        except Exception:
            pass
        return resp.content[0].text.strip()
    except Exception as e:
        _logger.warning("clipboard LLM call failed: %s", e)
        return ""


def _handle_clipboard(question: str) -> str:
    """Read the OS clipboard, do what was asked with Claude, and (for
    transforms) write the result straight back."""
    if os.name != "nt":
        return "Clipboard control only works on the PC, sir."
    try:
        import wivexx_clipboard as _clip
    except Exception:
        return "The clipboard module isn't available, sir."
    q = question.lower()
    if any(w in q for w in ("clear", "empty", "wipe")):
        return ("Cleared your clipboard, sir." if _clip.set_text("")
                else "I couldn't clear the clipboard, sir.")
    text = _clip.get_text()
    if not text or not text.strip():
        return "Your clipboard is empty, sir — copy something first."

    mlang = re.search(r"(?:translate|translation)\b.*?\b(?:to|into|in)\s+"
                      r"([a-zA-Z]+)", q)
    if "translate" in q or mlang:
        lang = (mlang.group(1) if mlang else "English").capitalize()
        out = _clip_llm(f"Translate the following text into {lang}. Keep the "
                        f"meaning and formatting.", text)
        if out and _clip.set_text(out):
            return (f"Translated to {lang} and copied it back to your "
                    f"clipboard, sir.")
        return (f"I translated it but couldn't write it back, sir. Here it "
                f"is: {out[:300]}") if out else "The translation failed, sir."
    if any(w in q for w in ("summar", "tl;dr", "tldr", "gist", "key points")):
        out = _clip_llm("Summarize the following text concisely.", text,
                        max_tokens=400)
        return f"Here's the gist, sir: {out}" if out else "I couldn't summarise that, sir."
    if any(w in q for w in ("explain", "what does", "what's this", "meaning")):
        out = _clip_llm("Explain the following text clearly and briefly.",
                        text, max_tokens=400)
        return out or "I couldn't explain that, sir."
    if any(w in q for w in ("rewrite", "reword", "rephrase", "improve",
                            "polish", "professional", "proofread", "fix",
                            "correct", "shorten", "expand", "formal", "casual")):
        instr = ("Rewrite the following text to be clearer and well-written, "
                 "keeping the original meaning and language.")
        if "professional" in q or "formal" in q:
            instr = "Rewrite the following text in a professional, polished tone."
        elif "shorten" in q:
            instr = "Make the following text shorter and tighter, keeping the key points."
        elif "expand" in q:
            instr = "Expand the following text with more detail, same tone."
        elif any(w in q for w in ("fix", "correct", "proofread")):
            instr = "Fix the spelling, grammar and punctuation of the following text."
        out = _clip_llm(instr, text)
        if out and _clip.set_text(out):
            return ("Done — I rewrote it and copied the new version to your "
                    "clipboard, sir.")
        return (f"Here it is, sir: {out[:300]}") if out else "That rewrite failed, sir."
    # default → read it aloud
    preview = text.strip()
    spoken = preview if len(preview) <= 600 else preview[:600] + "…"
    return f"Your clipboard has: {spoken}"


def _handle_winctl(question: str):
    """Window management. Returns a spoken string, or None when the phrase
    isn't actually a window command (so the turn falls through)."""
    if os.name != "nt":
        return "Window control only works on the PC, sir."
    try:
        import wivexx_winctl as _wc
    except Exception:
        return None
    qs = question.strip()

    m = _WIN_MOVE_RE.search(qs)
    if m:
        app = m.group(1).strip(" .,"); target = m.group(2).lower()
        hwnd = _wc.find_window(app)
        if not hwnd:
            return f"I couldn't find a window for {app}, sir."
        ok = _wc.move_to_monitor(hwnd, target)
        return (f"Moved {app} to the {target} monitor, sir." if ok
                else f"I found {app} but couldn't move it, sir.")

    m = _WIN_MINALL_RE.search(qs)
    if m:
        keep = (m.group(2) or "").strip(" .,")
        if keep:
            kept, n = _wc.minimize_all_except(keep)
            if kept:
                return (f"Minimised everything except {kept}, sir — {n} "
                        f"window{'s' if n != 1 else ''} tucked away.")
            return (f"I couldn't find {keep}, sir, so I left your windows "
                    f"as they were.")
        _, n = _wc.minimize_all_except("")
        return f"Minimised {n} window{'s' if n != 1 else ''}, sir."

    m = _WIN_ACT_RE.search(qs)
    if m:
        verb = m.group(1).lower(); app = m.group(2).strip(" .,").lower()
        if app in ("everything", "all", "it", "this", "that", "the rest",
                   "all windows", "every window"):
            return None
        action = ("minimize" if verb.startswith("minimi")
                  else "maximize" if verb.startswith("maximi")
                  else "restore" if "restore" in verb or verb.startswith("un")
                  else "focus")
        hwnd = _wc.find_window(app)
        if not hwnd:
            return None                       # probably not a window command
        fn = {"minimize": _wc.minimize, "maximize": _wc.maximize,
              "restore": _wc.restore, "focus": _wc.focus}[action]
        ok = fn(hwnd)
        word = {"minimize": "Minimised", "maximize": "Maximised",
                "restore": "Restored", "focus": "Brought"}[action]
        tail = " to the front" if action == "focus" else ""
        return (f"{word} {m.group(2).strip(' .,')}{tail}, sir." if ok
                else f"I found {app} but couldn't {action} it, sir.")
    return None


# ── v1.16: Google Calendar — read agenda / first / next, and book events ───────
_CAL_QUERY_RE = re.compile(
    r"\b(?:first|next|last)\s+(?:meeting|event|appointment|appt|call)s?\b"
    r"|\bwhat'?s?\s+next\b(?=.*\b(?:calendar|schedule|meeting|agenda|today|"
    r"tomorrow)\b)"
    r"|\bwhat'?s?\s+(?:on\s+)?my\s+(?:calendar|schedule|agenda)\b"
    r"|\b(?:on|in)\s+my\s+(?:calendar|schedule|agenda)\b"
    r"|\bwhat\s+does\s+my\s+(?:day|schedule|week)\s+look\s+like\b"
    r"|\b(?:do\s+i\s+have|have\s+i\s+got|any)\s+(?:any\s+)?(?:meetings?|"
    r"events?|appointments?)\b"
    r"|\bwhat'?s?\s+my\s+(?:schedule|agenda)\b",
    re.IGNORECASE,
)
_CAL_CREATE_RE = re.compile(
    # v1.34: "at\s+\d" used to end with \b which can't sit between "1" and "pm"
    # — "schedule lunch at 1pm" silently missed. \d[\w:]* absorbs 1pm/1:30pm.
    r"\b(?:schedule|book|set\s+up|block\s+out|add|create|put)\b"
    r"(?=.*\b(?:meeting|event|appointment|appt|call|time\s*block|block|"
    r"reminder|focus\s*block|session|on\s+my\s+calendar|at\s+\d[\w:]*|at\s+noon)\b)",
    re.IGNORECASE,
)


_OVERLAY_RE = re.compile(
    r"\b(show|hide|toggle|open|close|bring\s+up)\b[\w\s]{0,20}\b"
    r"(status\s+overlay|status\s+widget|status\s+panel|status\s+hud|"
    r"hud|dashboard|the\s+overlay)\b"
    r"|\b(status\s+overlay|status\s+widget|status\s+dashboard)\b",
    re.IGNORECASE,
)

# v1.30: the live hardware HUD (CPU/GPU/RAM/VRAM/net) — kept distinct from the
# assistant status overlay above (this one is performance/specs).
_PERF_HUD_RE = re.compile(
    r"\b(show|hide|toggle|open|close|bring\s+up)\b[\w\s]{0,24}\b"
    r"(performance(?:\s+(?:hud|overlay|monitor|panel|stats))?|perf\s+(?:hud|overlay)|"
    r"(?:system|hardware|pc|gpu|cpu)\s+(?:stats|monitor|usage|overlay|hud)|"
    r"fps\s+(?:counter|overlay|hud)|system\s+monitor)\b",
    re.IGNORECASE,
)

# v1.30: performance mode (was "gaming mode") — free up the PC by suspending
# background bloat + High-Perf power plan. "gaming"/"game" kept as aliases.
_GAMING_MODE_RE = re.compile(
    r"\b(enable|disable|turn\s+on|turn\s+off|start|stop|toggle|activate|"
    r"deactivate|enter|exit|leave|end|switch\s+to|go\s+into|engage|"
    r"disengage)?\s*(?:the\s+)?\b(?:performance|gam(?:e|ing))\s+mode\b",
    re.IGNORECASE,
)
_GM_OFF_VERBS = ("disable", "turn off", "stop", "deactivate", "exit", "leave",
                 "end", "disengage")
_GM_ON_VERBS = ("enable", "turn on", "start", "activate", "enter", "switch to",
                "go into", "engage")

# v1.30: transparent in-game performance OSD (click-through, see-through).
_OSD_RE = re.compile(
    r"\b(show|hide|toggle|enable|disable|turn\s+on|turn\s+off|close|open)?\s*"
    r"\b(?:gaming|game|in-?game|transparent)\s+(?:overlay|osd|hud)\b",
    re.IGNORECASE,
)

# v1.30: on-demand optimizer — free RAM (real), report CPU/GPU hogs.
_OPTIMIZE_RE = re.compile(
    r"\b(?:optimi[sz]e|free\s+up|clean(?:\s+up)?|clear|boost|speed\s+up)\b"
    r"[\w\s]{0,16}?\b(ram|memory|cpu|gpu|vram|pc|computer|system|performance)\b",
    re.IGNORECASE,
)
_OPT_FIELD = {"ram": "ram", "memory": "ram", "cpu": "cpu", "gpu": "gpu", "vram": "gpu"}

# v1.30: on-demand internet speed test (our own Cloudflare probe).
_SPEEDTEST_RE = re.compile(
    r"\b(?:internet|connection|download|network|wi-?fi)\s+speed\b"
    r"|\bspeed\s*test\b|\bhow\s+fast\s+is\s+my\s+(?:internet|connection|wi-?fi)\b"
    r"|\btest\s+my\s+(?:internet|connection|speed)\b",
    re.IGNORECASE,
)

# v1.31: transcribe an audio/video FILE to text (+ .srt). Reuses the Whisper model.
_TRANSCRIBE_RE = re.compile(
    r"\b(?:transcribe|transcription|transcript|caption|subtitle|subtitles)\b"
    r"[\w\s']{0,24}?\b(?:audio|video|file|recording|clip|movie|media|song|track|"
    r"footage|mp3|mp4|podcast|interview|meeting)\b"
    r"|\btranscribe\b|\bmake\s+(?:a\s+)?transcript\b|\bgenerate\s+(?:sub|caption)",
    re.IGNORECASE,
)


def _do_transcribe(path, gui_mod, question="transcribe"):
    """Shared transcription flow — used by the voice command AND drag-and-drop.
    Posts a live 'working…' entry to the activity/notifications tab, runs the job
    on a daemon thread, and on finish leaves a history entry with a clickable
    'open transcript' button. Returns True (handled)."""
    try:
        import wivexx_transcribe as _wt
    except Exception as _ie:
        _logger.warning("wivexx_transcribe import failed: %s", _ie)
        _wt = None
    if not path:
        resp = "No file picked, sir — transcription cancelled."
    elif _wt is None or not _wt.is_media(path):
        resp = "That doesn't look like an audio or video file I can transcribe, sir."
    else:
        stem = os.path.splitext(os.path.basename(path))[0]
        jid = "tr-%d" % int(time.time() * 1000)

        def _worker(_p=path, _stem=stem, _jid=jid, _gm=gui_mod):
            try:
                try:
                    _wt.set_logger(_logger)
                except Exception:
                    pass
                _last = [0]

                def _prog(frac):
                    pct = int(frac * 100)
                    if pct - _last[0] >= 2:
                        _last[0] = pct
                        try:
                            if _gm:
                                _gm.transcribe_progress(_jid, pct)
                        except Exception:
                            pass

                res = _wt.transcribe_file(_p, on_progress=_prog)
                if not res or res.get("error"):
                    if _gm:
                        _gm.transcribe_finished(_jid, "Transcription failed — %s" % _stem, status="failed")
                    speak("I couldn't read that file, sir."); return
                if not res.get("text"):
                    if _gm:
                        _gm.transcribe_finished(_jid, "No speech found — %s" % _stem, status="failed")
                    speak("That file had no speech I could make out, sir."); return
                out = res.get("txt_path") or res.get("srt_path") or ""
                _lang = {"en": "English", "es": "Spanish"}.get((res.get("language") or "").lower())
                if _gm:
                    _gm.transcribe_finished(
                        _jid, "Transcript ready — %s" % _stem,
                        sub="%d words%s" % (res.get("words", 0), (" · %s" % _lang) if _lang else ""),
                        file=out, status="done")
                speak("Done, sir — %d words%s from %s. The transcript is in your "
                      "notifications, ready to open." % (
                          res.get("words", 0), (" in %s" % _lang) if _lang else "", _stem))
            except Exception as _we:
                _logger.warning("transcription failed: %s", _we)
                try:
                    if _gm:
                        _gm.transcribe_finished(_jid, "Transcription failed — %s" % _stem, status="failed")
                except Exception:
                    pass
                speak("The transcription failed, sir.")

        try:
            if gui_mod:
                gui_mod.transcribe_started(jid, "Transcribing — %s" % stem, "working…")
        except Exception:
            pass
        speak("On it, sir — transcribing %s now. I'll add it to your notifications and "
              "tell you when it's ready." % stem)
        threading.Thread(target=_worker, daemon=True).start()
        _log_turn("user", question)
        _log_turn("assistant", "Transcription started.")
        return True
    _log_turn("user", question)
    _log_turn("assistant", resp)
    speak(resp)
    return True


# v1.30: Lightroom import is now VOICE-only (the global ctrl+shift+l hotkey was
# unbound — it collided with Lightroom's Lights Out). Needs an explicit verb so a
# casual "open Lightroom" doesn't import.
_LR_IMPORT_RE = re.compile(
    r"\b(?:import|load|send|bring|pull)\b[\w\s']{0,24}\blightroom\b"
    r"|\blightroom\b[\w\s']{0,12}\bimport\b",
    re.IGNORECASE,
)


def _handle_calendar_query(question: str) -> str:
    try:
        import wivexx_calendar as _cal
    except Exception:
        return "Calendar support isn't available, sir."
    if _cal.auth_status() == "need_credentials":
        return _cal.setup_help()
    try:
        q = _cal.parse_query(question)
        if q["kind"] == "first":
            return _cal.describe_first(_cal.first_meeting(q["day_offset"]),
                                       q["label"])
        if q["kind"] == "next":
            return _cal.describe_next(_cal.next_event())
        return _cal.describe_events(_cal.agenda(q["day_offset"]), q["label"])
    except FileNotFoundError:
        return _cal.setup_help()
    except Exception as e:
        _logger.warning("calendar query failed: %s", e)
        return "I couldn't reach your calendar, sir."


def _handle_calendar_create(question: str) -> str:
    try:
        import wivexx_calendar as _cal
    except Exception:
        return "Calendar support isn't available, sir."
    if _cal.auth_status() == "need_credentials":
        return _cal.setup_help()
    req = _cal.parse_event_request(question)
    if req["start"] is None:
        return ("I need a time, sir — try 'schedule a call tomorrow at 3pm "
                "for an hour'.")
    try:
        _cal.create_event(req["title"], req["start"], req["duration_min"])
        dur = req["duration_min"]
        durtxt = (f"{dur // 60} hour{'s' if dur // 60 != 1 else ''}"
                  if dur % 60 == 0 else f"{dur} minutes")
        return (f"Booked '{req['title']}' {req['label']} at "
                f"{_cal.fmt_time(req['start'])} for {durtxt}, sir.")
    except FileNotFoundError:
        return _cal.setup_help()
    except Exception as e:
        _logger.warning("calendar create failed: %s", e)
        return "I couldn't create that event, sir."


# ── v1.17: voice biometrics ───────────────────────────────────────────────────
_ENROLL_RE = re.compile(
    r"\b(?:enroll|register|set\s*up|learn|record|train)\s+(?:my\s+)?voice"
    r"(?:\s*(?:id|print|biometrics?))?\b"
    r"|\b(?:set\s*up|enable)\s+voice\s+(?:id|verification|biometrics?)\b",
    re.IGNORECASE,
)


def _voice_ok_for_sensitive(group: str):
    """(allowed, score).  Voice-ID verification for sensitive commands was REMOVED
    on user request (2026-06-23) — they no longer want the "verify your voice" step
    for shutdown / memory / etc. These commands still keep their own text confirm
    (e.g. "confirm shutdown"). Always allows."""
    return True, 1.0


def _handle_voice_enroll() -> str:
    try:
        import wivexx_voiceid as _vid
    except Exception:
        return "Voice ID isn't available, sir."
    if not _vid.available():
        return ("Voice ID needs the resemblyzer library, sir — install it with "
                "pip install resemblyzer.")
    if (_remote_capture is not None
            and threading.get_ident() == _remote_capture_tid):
        return ("Enrolling your voice has to be done at the PC with the mic, "
                "sir — say 'enroll my voice' there.")
    prompts = [
        "Sample one of three. Say: my voice is my passport, verify me.",
        "Sample two. Say: hey Wivexx, this is me, and I authorise this.",
        "Last one. Say: confirm my identity and proceed.",
    ]
    samples = []
    for ph in prompts:
        speak(ph)
        audio = _record_triggered(max_wait=8.0)
        if audio is not None and len(audio) > SAMPLE_RATE * 0.6:
            samples.append((audio, SAMPLE_RATE))
    if len(samples) < 2:
        return ("I didn't catch enough clear audio, sir — try enrolling again "
                "somewhere quiet.")
    res = _vid.enroll(samples)
    if res.get("ok"):
        return (f"Your voiceprint is saved, sir — {res['samples']} samples. "
                f"Shutdown and memory-wipe commands now need your voice.")
    return f"Enrollment didn't take, sir — {res.get('error', 'unknown error')}."


# ── v1.58: guided wake-word tuning ("tune the wake word") ──────────────────────
# The user says the wake word ~5 times; what the TINY wake model actually hears
# ("hey why mix", "y bix"…) is stored per-language in wivexx_wake_profile and
# becomes first-class wake variants + fuzzy refs — recognition tuned to THIS
# voice. Raw clips are kept as WAVs (future hey_wivexx.onnx training data).
_WAKE_TUNE_RE = re.compile(
    r"\b(?:tune|calibrate|train|teach|enroll|improve|fix|adjust)\s+(?:the\s+|my\s+)?wake\s*[- ]?(?:up\s+)?word\b|\bwake\s*[- ]?word\s+(?:training|tuning|calibration|setup)\b",
    re.IGNORECASE,
)
_WAKE_ENROLL_PROMPTS = {
    "en": {
        "intro": ("Let's tune my ears to your voice, sir. I'll ask you to say "
                  "the wake word six times — speak naturally after each prompt."),
        "steps": ["One — say 'Hey Wivexx' now.", "Two — 'Hey Wivexx' again.",
                  "Three — once more, naturally.", "Four — now just 'Wivexx'.",
                  "Five — 'Wivexx' again.", "Six — 'Wivexx' one last time.",
                  "Once more, please.", "Again, please.", "Last try, please."],
        "ok":   "Done, sir — I've learned how you say it. %d samples captured.",
        "fail": ("I didn't catch enough clear audio, sir — say 'tune the wake "
                 "word' to try again somewhere quieter."),
    },
    "es": {
        "intro": ("Vamos a afinar mis oídos a su voz, señor. Le pediré decir la "
                  "palabra de activación seis veces — hable con naturalidad "
                  "después de cada aviso."),
        "steps": ["Uno — diga 'Oye Wivexx' ahora.", "Dos — 'Oye Wivexx' otra vez.",
                  "Tres — una vez más, con naturalidad.", "Cuatro — ahora solo 'Wivexx'.",
                  "Cinco — 'Wivexx' de nuevo.", "Seis — 'Wivexx' por última vez.",
                  "Otra vez, por favor.", "De nuevo, por favor.", "Último intento."],
        "ok":   "Listo, señor — ya aprendí cómo lo dice. Capturé %d muestras.",
        "fail": ("No capté suficiente audio claro, señor — diga 'ajusta la "
                 "palabra de activación' para intentarlo de nuevo."),
    },
}


def _wake_enroll_marked(lang: str) -> bool:
    try:
        import wivexx_settings as _ws
        return lang in (_ws.get("wake_enroll_prompted", []) or [])
    except Exception:
        return True     # can't read settings → never auto-nag


def _wake_enroll_mark(lang: str) -> None:
    try:
        import wivexx_settings as _ws
        with _wake_enroll_lock:      # review fix: atomic read-modify-write
            done = list(_ws.get("wake_enroll_prompted", []) or [])
            if lang not in done:
                done.append(lang)
                _ws.set("wake_enroll_prompted", done)
    except Exception:
        pass


def _enroll_wake_profile(lang: str = "en", samples: int = 6) -> str:
    """The guided wake-word tuning flow. Prompts IN the enrolled language,
    records each attempt, keeps what the tiny wake model heard, and stores the
    renderings + WAVs in the per-language profile. Returns the spoken outcome."""
    global _wake_enrolling
    try:
        import wivexx_wake_profile as _wp
    except Exception:
        return "Wake-word tuning isn't available, sir."
    if (_remote_capture is not None
            and threading.get_ident() == _remote_capture_tid):
        return ("Wake-word tuning has to be done at the PC with the mic, "
                "sir — say 'tune the wake word' there.")
    # v1.58 review fix: atomic check-and-set — the flow can be triggered from
    # three different threads (voice turn, boot offer, Settings language
    # switch); the unlocked check let two enrollments interleave prompts and
    # fight for the mic.
    with _wake_enroll_lock:
        if _wake_enrolling:
            return "I'm already tuning, sir."
        _wake_enrolling = True
    lang = "es" if (lang or "").lower().startswith("es") else "en"
    P = _WAKE_ENROLL_PROMPTS[lang]
    _wake_enroll_mark(lang)          # any real attempt stops the boot auto-offer
    try:
        set_tray("listening", "Wivexx — tuning wake word")
        speak(P["intro"])
        got, attempts, renders, dead_air = 0, 0, [], 0
        while got < samples and attempts < samples + 3:
            speak(P["steps"][min(attempts, len(P["steps"]) - 1)])
            audio = _record_triggered(max_wait=8.0, max_secs=3.0)
            attempts += 1
            if audio is None:
                # v1.58 review fix: nobody is answering (boot auto-offer with an
                # empty room) — two consecutive silent prompts end the flow
                # instead of talking to nobody for the full attempt budget.
                dead_air += 1
                if dead_air >= 2:
                    break
                continue
            dead_air = 0
            if len(audio) < SAMPLE_RATE * 0.3:
                continue
            try:
                txt = _transcribe_wake(audio)
            except Exception:
                txt = ""
            t = (txt or "").lower().strip(" .,!?¿¡")
            # shared junk gate (empty / sentence / hallucination / lead-ins
            # only) — same predicate adaptive learning uses; ask again instead.
            # NO phonetic gate here: the user was ASKED to say the word, so an
            # unrecognizable rendering is exactly the data enrollment exists for.
            if not _plausible_wake_render(t):
                continue
            if _wp.add_sample(lang, t, audio, SAMPLE_RATE):
                renders.append(t)
                got += 1
        if got >= 3:
            _logger.info("wake profile (%s): %d samples — renderings %r",
                         lang, got, renders)
            return P["ok"] % got
        return P["fail"]
    finally:
        _wake_enrolling = False
        set_tray("standby", "Wivexx — standby")


def _wake_enroll_offer(lang: str) -> None:
    """Auto-offer the wake tuning the FIRST time a language is used (boot with
    no profile, or a Settings language switch to a new language). Runs the flow
    at most once per language (settings marker) and never while the mic is
    otherwise busy."""
    try:
        import wivexx_wake_profile as _wp
        lang = "es" if (lang or "").lower().startswith("es") else "en"
        if _wp.enrolled(lang) or _wake_enroll_marked(lang):
            return
        if _in_active_session or _call_mode or _mic_muted or _wake_enrolling:
            return                    # busy — offer again next boot instead
        resp = _enroll_wake_profile(lang)
        speak(resp)
    except Exception as _we_e:
        _logger.debug("wake enroll offer skipped: %s", _we_e)


# ── v1.17: screen-click overlay ───────────────────────────────────────────────
_SCREEN_LABEL_RE = re.compile(
    r"\b(?:number|label|tag|mark)\s+(?:the\s+)?screen\b"
    r"|\bshow\s+(?:me\s+)?(?:the\s+)?clickable(?:\s+(?:items?|things?|elements?))?\b"
    r"|\bwhat\s+can\s+i\s+click\b"
    r"|\b(?:enable|start|turn\s+on)\s+click\s+mode\b"
    r"|\bclick\s+mode\b",
    re.IGNORECASE,
)
# Speech-to-text gives us the WORD ("click one"), not the digit, so accept both —
# number words 0-99 plus the homophones Whisper commonly emits for small numbers.
_NUM_WORDS = {
    "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
    "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
    "thirteen": 13, "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17,
    "eighteen": 18, "nineteen": 19, "twenty": 20, "thirty": 30, "forty": 40,
    "fifty": 50, "sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90,
    # common Whisper homophones for the small numbers
    "won": 1, "to": 2, "too": 2, "tree": 3, "for": 4, "fore": 4, "ate": 8,
}
_TENS_WORDS = {"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50,
               "sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90}
_UNIT_WORDS = {k: v for k, v in _NUM_WORDS.items() if 1 <= v <= 9}


def _parse_spoken_int(tok):
    """'5' / 'five' / 'twenty-one' / 'won' → int, else None."""
    if tok is None:
        return None
    s = str(tok).strip().lower().replace("-", " ")
    if s.isdigit():
        return int(s)
    parts = s.split()
    if len(parts) == 2 and parts[0] in _TENS_WORDS and parts[1] in _UNIT_WORDS:
        return _TENS_WORDS[parts[0]] + _UNIT_WORDS[parts[1]]
    if len(parts) == 1 and parts[0] in _NUM_WORDS:
        return _NUM_WORDS[parts[0]]
    m = re.search(r"\d{1,3}", s)
    return int(m.group()) if m else None


_NUM_WORD_ALT = "|".join(sorted(_NUM_WORDS.keys(), key=len, reverse=True))
_CLICK_N_RE = re.compile(
    r"\b(?:click|press|select|tap|choose|hit)\s+(?:on\s+)?(?:number\s+|item\s+|#\s*)?"
    r"(\d{1,3}|(?:" + _NUM_WORD_ALT + r")(?:[\s-](?:one|two|three|four|five|six|"
    r"seven|eight|nine))?)\b",
    re.IGNORECASE,
)


def _handle_screen_label() -> str:
    global _screen_click_map
    try:
        import wivexx_screenui as _su
    except Exception:
        return "Screen control isn't available, sir."
    if not _su.available():
        return ("That needs the uiautomation library, sir — install it with "
                "pip install uiautomation.")
    items = _su.enumerate_clickables(max_items=50)
    _screen_click_map = {it["n"]: it for it in items}
    if not items:
        return "I couldn't find anything clickable on the active window, sir."
    if _wivexx_gui is not None and hasattr(_wivexx_gui, "show_click_overlay"):
        try:
            _wivexx_gui.show_click_overlay(
                [{"n": it["n"], "rect": it["rect"], "name": it["name"]}
                 for it in items])
        except Exception as _oe:
            _logger.warning("click overlay draw failed: %s", _oe)
    n = len(items)
    return (f"{n} clickable item{'s' if n != 1 else ''} numbered, sir — "
            f"say 'click' and the number.")


def _handle_screen_click(num: int) -> str:
    try:
        import wivexx_screenui as _su
    except Exception:
        return "Screen control isn't available, sir."
    item = _screen_click_map.get(num)
    if not item:
        return (f"I don't have a number {num} on screen, sir — say 'number the "
                f"screen' first.")
    ok = _su.click(item)
    if _wivexx_gui is not None and hasattr(_wivexx_gui, "hide_click_overlay"):
        try:
            _wivexx_gui.hide_click_overlay()
        except Exception:
            pass
    _screen_click_map.clear()
    label = item.get("name") or f"item {num}"
    return (f"Clicked {label}, sir." if ok
            else f"I couldn't click {label}, sir.")


_DISK_ANALYZE_RE = re.compile(
    r"\b(?:analy[sz]e|scan|check|clean\s*up|free\s+up(?:\s+space)?)\b.{0,40}"
    r"\b(?:drives?|disks?|storage|space|hard\s+drives?|c\s+and\s+d)\b"
    r"|\b(?:biggest|largest|big|huge|old|stale|unused)\b.{0,30}"
    r"\bfiles?\b"
    r"|\bwhat(?:'?s| is)\s+(?:taking|using|eating)\s+(?:up\s+)?(?:my\s+)?"
    r"(?:space|storage|disk)\b"
    r"|\bfind\s+(?:me\s+)?(?:large|big|old|stale|unused)\s+files?\b",
    re.IGNORECASE,
)

_PENDING_QUERY_RE = re.compile(
    r"\bwhat(?:'?s| is| are)\s+(?:my\s+|the\s+)?(?:pending|scheduled|active|"
    r"on\s+the\s+go|going\s+on|running|in\s+progress|queued)\b"
    r"|\b(?:any|anything)\s+(?:pending|scheduled|active|running)\b"
    r"|\b(?:list|show|tell\s+me)\s+(?:my\s+|the\s+)?(?:pending|active|scheduled)"
    r"\s+(?:tasks?|timers?|jobs?)\b"
    r"|\bwhat\s+(?:are\s+you|tasks?\s+are)\s+(?:working\s+on|doing|tracking)\b",
    re.IGNORECASE,
)


def _drain_timer_queue() -> None:
    """Speak any timer completion notifications that have accumulated.
    Call this after every spoken WIVEXX response in _active_session."""
    while not _timer_done_queue.empty():
        try:
            lbl = _timer_done_queue.get_nowait()
        except Exception:
            break
        msg = f"By the way, sir — your {lbl} timer has finished."
        _log_turn("assistant", msg)
        speak(msg)


# ── Non-interrupting announcements (v1.8.1) ───────────────────────────────────
# Background notices (a drive crossing 85 %, etc.) queue here and are spoken
# only when WIVEXX is idle — they can never cut off something he's saying or a
# turn the user is having.
_announce_queue: "queue.Queue[str]" = queue.Queue()


def announce_async(text: str) -> None:
    """Thread-safe: queue a notice to be spoken when WIVEXX is next idle."""
    try:
        _announce_queue.put_nowait(text)
    except Exception:
        pass


def _notify_enabled(key: str) -> bool:
    """v1.32: is the recurring notification *key* allowed to fire? (toggled from
    the GUI Activity → recurring notifications). Defaults to allowed on error."""
    try:
        import wivexx_notify_prefs as _np
        return _np.is_enabled(key)
    except Exception:
        return True


def _notify_announce(key: str, text: str) -> None:
    """Announce *text* only if the recurring notification *key* is enabled."""
    if _notify_enabled(key):
        announce_async(text)


def _gui_busy(key: str, text) -> None:
    """v1.13: show (text) or clear (None) a named 'working…' line in the GUI
    activity row, so long jobs are visually obvious. Thread-safe, best-effort."""
    if _wivexx_gui:
        try:
            _wivexx_gui.set_busy(key, text)
        except Exception:
            pass


def _announce_drainer() -> None:
    """Speak queued notices, but only while idle: not speaking, no PTT capture,
    no active turn, not in call mode, not in dictation."""
    while True:
        msg = _announce_queue.get()         # blocks until something is queued
        while True:
            idle = (not _tts_active and not _ptt_recording
                    and not _IN_REQUEST_MODE
                    and _remote_capture is None)
            if idle and _TURN_LOCK.acquire(blocking=False):
                try:
                    # NOTE: speak() already mirrors the line to the GUI/phone via
                    # _console_turn — don't call it here too or the announcement
                    # shows up twice. _log_turn only appends to the export log.
                    _log_turn("assistant", msg)
                    speak(msg)
                finally:
                    _TURN_LOCK.release()
                break
            time.sleep(0.5)                  # wait for a quiet moment


# ══════════════════════════════════════════════════════════════════════════════
#  IN-CHARACTER FALLBACK + FEATURE-REQUEST LOGGING
# ══════════════════════════════════════════════════════════════════════════════

# ── Capability self-knowledge (Item 5) ────────────────────────────────────────
_CAPABILITIES: dict[str, str] = {
    "open app":        "I can open applications — say 'open Discord', 'launch Spotify', etc.",
    "close app":       "I can close applications — say 'close Chrome', 'close all browsers', etc.",
    "screenshot":      "I can take screenshots — say 'take a screenshot', 'capture both monitors', etc.",
    "timer":           "I can set timers — say 'set a timer for 30 minutes', 'alarm in 1 hour', etc.",
    "save conversation": "I can save our conversation — say 'save the conversation', 'export chat', etc.",
    "log request":     "I can log feature requests — say 'create a request', 'I have a request', etc.",
    "web search":      "I can search the web — just ask me any factual question.",
    "time and date":   "I can tell you the time or date — say 'what time is it', 'what's today's date'.",
    "social trackers": "I can track your social media growth — say 'track instagram' plus the handle, then 'social media report'.",
    "captions":        "I can write post captions with researched hashtags — say 'create a caption for' plus your post topic.",
}

_CAPABILITY_QUERY_RE = re.compile(
    r'\b(?:what\s+can\s+you\s+do|what\s+are\s+your\s+features|what\s+do\s+you\s+support'
    r'|list\s+your\s+commands|help\s+me|show\s+me\s+what\s+you\s+can\s+do)\b',
    re.IGNORECASE,
)

_ACTIVE_TIMER_QUERY_RE = re.compile(
    r'\b(?:how\s+much\s+time(?:\s+is)?\s+left|how\s+long\s+(?:until|till|before)\s+(?:the\s+|my\s+)?timer'
    r'|timer\s+status|time\s+remaining|time\s+left\s+on\s+(?:my|the)\s+timer'
    r'|(?:do\s+i\s+have\s+)?any\s+timers?\s+(?:running|left|active|going)'
    r'|(?:are\s+there\s+)?(?:any\s+)?active\s+timers?)\b',
    re.IGNORECASE,
)

# ── "Add app" voice command regex (Item 3) ─────────────────────────────────────
_ADD_APP_RE = re.compile(
    r'\badd\s+app\s+(.+)',
    re.IGNORECASE,
)

# Verbs that signal a system command intent (used to route unrecognised commands)
_SYSTEM_ACTION_RE = re.compile(
    r'\b(?:open|close|launch|run|turn|set|show|hide|start|stop|play|pause|mute'
    r'|lock|screenshot|quit|exit|kill|shutdown|enable|disable|install|uninstall)\b',
    re.IGNORECASE,
)

# Questions beginning with an interrogative are informational — skip fallback
_INTERROGATIVE_RE = re.compile(
    r'\b(?:how|what|when|where|why|who|which|is|are|was|were|do|does|did'
    r'|can|could|would|should|will|if|about|tell\s+me|give\s+me'
    r'|i\s+(?:want|wanted|need|would\s+like|\'d\s+like)\s+to\s+know'
    r'|i\s+was\s+wondering|i\s+have\s+a\s+question)\b',
    re.IGNORECASE,
)

# ── Manual feature-request voice command ──────────────────────────────────────
_MANUAL_REQUEST_RE = re.compile(
    r'''(?x)
    \b(?:create|add|log|register|make|submit|file|note|record)\s+a\s+(?:feature\s+)?request\b
    |\b(?:add|log|put|note|record|save)\s+(?:that|this|it)\s+(?:as\s+a?|to\s+a?|in\s+a?)\s*(?:feature\s+)?request\b
    |\bi\s+have\s+a\s+(?:feature\s+)?request\b
    |\b(?:log|file)\s+(?:this|that)\s+(?:as\s+a?\s*)?(?:feature\s+)?request\b
    ''',
    re.IGNORECASE,
)

# ── Save conversation regex (module-level so _extract_intent can use it) ──────
_SAVE_CONV_RE = re.compile(
    r'\b(?:'
    r'save\s+(?:the\s+|our\s+|this\s+)?(?:conversation|chat|session|history)'
    r'|export\s+(?:the\s+|our\s+|this\s+)?(?:conversation|chat|history|session)'
    r'|write\s+down\s+(?:our|this|the)\s+(?:chat|conversation)'
    r'|log\s+(?:the\s+|this\s+)?conversation'
    r'|record\s+(?:our\s+|this\s+|the\s+)?conversation'
    r'|save\s+what\s+we\s+(?:talked|said|discussed)'
    r'|save\s+everything'
    r'|save\s+the\s+session'
    r'|dump\s+(?:the\s+)?conversation'
    r'|backup\s+(?:the\s+)?conversation'
    r')\b',
    re.IGNORECASE,
)

# ── Deploy-to-stable regex ────────────────────────────────────────────────────
_DEPLOY_STABLE_RE = re.compile(
    r'\b(?:'
    r'deploy\s+(?:to\s+)?stable'
    r'|update\s+(?:the\s+)?stable\s+version'
    r'|push\s+(?:to\s+)?stable'
    r'|save\s+(?:as\s+)?stable'
    r')\b',
    re.IGNORECASE,
)

# ── Intent confidence scoring — concept-question guard ────────────────────────
_CONCEPT_QUESTION_RE = re.compile(
    r'^(?:what\s+is|what\s+are|what\'?s\s+(?!new\b|up\b)|how\s+(?:do\s+i|does|can\s+i|would|should|to)'
    r'|tell\s+me\s+(?:about|how)|explain|why\s+(?:is|does|do|would)|who\s+is|when\s+(?:did|was|is)'
    r'|can\s+you\s+(?:explain|describe|tell\s+me\s+about|help\s+me\s+understand)'
    r'|how\s+(?:important|useful|different|similar|good|bad|much|many))',
    re.IGNORECASE,
)


def _extract_intent(text: str) -> Optional[tuple]:
    """
    Unified intent detector with confidence scoring.
    Returns (intent_type, arg) or None.
    Intent types: "open_app", "close_app", "screenshot",
                  "timer", "save_conv", "request"

    Confidence rules:
    - App open/close: requires a recognized registry entry within the match
                      prevents "open source software" / "close the chapter"
    - All app intents: suppressed if text looks like a concept question
                       prevents "how do I start using X?" triggering app open
    - Screenshot: tight phrase matching only, no past-tense
    """
    # Strip fillers before analysis
    t_clean = _SPEECH_FILLER_RE.sub(' ', text).strip()
    q = t_clean.lower()

    # ── Conversational-context guards ─────────────────────────────────────────
    # These prevent intent regexes from firing on keywords buried inside long
    # conversational sentences (e.g. "what's the latest version of Minecraft"
    # firing the version-check command).
    _words = t_clean.split()
    _word_count = len(_words)
    _has_explicit_prefix = bool(re.search(
        r'\b(wivexx|hey\s+wivexx|ok\s+wivexx)\s*[,.]?\s*\w', t_clean, re.I))

    # GUARD 1: Long utterances (>12 words) require the command verb in the first
    # 5 words.  If the trigger keyword only appears deep in the sentence it is
    # almost certainly conversational, not a command.
    if _word_count > 12 and not _has_explicit_prefix:
        _first_five = ' '.join(_words[:5]).lower()
        _cmd_verbs = (r'\b(open|close|launch|start|set|turn|take|screenshot|'
                      r'save|create|log|timer|alarm|remind|search|play|mute)\b')
        if not re.search(_cmd_verbs, _first_five, re.I):
            return None

    # GUARD 2: WH-questions / topical statements are almost always conversational.
    _statement_pats = [
        r'^(what|who|how|when|where|why|which|is|are|was|were|do|does|did'
        r'|can|could|would|should|tell\s+me|explain|describe)\b',
        r'\b(version\s+of|release\s+of|update\s+of|edition\s+of)\s+'
        r'(?!wivexx|myself|yourself)\w',
    ]
    for _spat in _statement_pats:
        if re.search(_spat, q, re.I):
            if not _has_explicit_prefix and _word_count > 7:
                return None

    # ── Screenshot ────────────────────────────────────────────────────────────
    if _wants_screen(text) or _wants_multi_monitor(text):
        _SHOT_VERBS = ["take", "capture", "screenshot", "grab", "snap", "show"]
        if _is_imperative_command(t_clean, _SHOT_VERBS):
            return ("screenshot", "")

    # ── Timer ─────────────────────────────────────────────────────────────────
    _TIMER_VERBS = ["set", "start", "create", "remind", "alert", "ping", "count"]
    if _TIMER_TRIGGER_RE.search(q) and _is_imperative_command(t_clean, _TIMER_VERBS):
        return ("timer", "")

    # ── Save conversation ─────────────────────────────────────────────────────
    if _SAVE_CONV_RE.search(q) and _is_imperative_command(t_clean, ["save", "export", "write", "log", "record", "dump", "backup"]):
        return ("save_conv", "")

    # ── Feature request ───────────────────────────────────────────────────────
    if _MANUAL_REQUEST_RE.search(q) and _is_imperative_command(t_clean, ["create", "add", "log", "register", "make", "submit", "file", "note", "record"]):
        return ("request", "")

    # ── Deploy to stable ──────────────────────────────────────────────────────
    if _DEPLOY_STABLE_RE.search(q) and _is_imperative_command(t_clean, ["deploy", "update", "push", "save"]):
        return ("deploy_stable", "")

    # ── Concept-question guard — skip all app intents for questions about ideas
    if _CONCEPT_QUESTION_RE.match(q):
        return None

    # ── App close: require recognized target ─────────────────────────────────
    _CLOSE_VERBS = ["close", "quit", "exit", "kill", "terminate", "shut"]
    if _is_imperative_command(t_clean, _CLOSE_VERBS):
        close_result = _detect_close_intent(t_clean)
        if close_result is not None:
            close_type, close_names = close_result
            if close_type == "all":
                return ("close_app", "all")
            if close_type == "category":
                return ("close_app", f"category:{close_names[0]}")
            # Named apps: at least one must be in registry
            if close_names:
                for cname in close_names:
                    cl = cname.lower()
                    if cl in _APP_REGISTRY:
                        return ("close_app", close_type)
                    if any(v in cl and len(v) >= 3 for v in _APP_REGISTRY):
                        return ("close_app", close_type)
                    if _CATEGORY_ALIASES.get(cl):
                        return ("close_app", close_type)

    # ── App open: require recognized app name in registry ────────────────────
    # "open source software" → "source software" → no registry match → None
    app_name_raw = _detect_app_intent(t_clean)
    if app_name_raw:
        cl = app_name_raw.lower()
        _found = cl in _APP_REGISTRY
        if not _found:
            _found = any(v in cl and len(v) >= 3 for v in _APP_REGISTRY)
        if _found:
            return ("open_app", app_name_raw)

    return None

# ── Self-terminate: "close/quit/shutdown wivexx", goodbye phrases, etc. ───────
# Extended in v1.3 to cover stop/terminate/turn-off/goodbye variants.
# All patterns that existed in the old regex are preserved; new ones added below.
_SELF_CLOSE_RE = re.compile(
    r'\b(?:'
    # Direct verb + target (original + new: stop, turn off, terminate)
    r'(?:close|quit|exit|shut\s*down|kill|stop|turn\s*off|terminate)\s+'
    r'(?:wivexx|j\.a\.r\.v\.i\.s\.?|claude|cloud|clod|the\s+assistant|yourself|your\s*self)'
    # Wivexx-prefixed shutdown commands: "wivexx shut down", "wivexx quit yourself"
    r'|wivexx\s+(?:close|quit|exit|shut\s*down|turn\s*off)\s*(?:yourself|down)?'
    # Goodbye / farewell patterns
    r'|(?:goodbye|bye|farewell)\s+wivexx'
    r'|wivexx\s+(?:goodbye|bye)'
    r')\b',
    re.IGNORECASE,
)

_UNKNOWN_CMD_RESPONSES: list[str] = [
    "That capability hasn't been programmed into my systems yet, sir.",
    "I'm afraid that function isn't in my current build, sir.",
    "That's not part of my skill set at the moment, sir.",
    "I don't have that routine loaded, sir.",
    "That subroutine doesn't exist in my current configuration, sir.",
]


def _handle_unknown_command(user_text: str) -> str:
    """Return an in-character WIVEXX response for unrecognized system commands.
    Suggests the nearest capability when similarity is above threshold (Item 5)."""
    best = max(_CAPABILITIES.keys(),
               key=lambda k: difflib.SequenceMatcher(None, user_text.lower(), k).ratio())
    score = difflib.SequenceMatcher(None, user_text.lower(), best).ratio()
    suffix = f" Did you mean to {best}?" if score > 0.4 else ""
    return random.choice(_UNKNOWN_CMD_RESPONSES) + suffix


# Phrases in Claude's reply that break the WIVEXX persona
_BREAK_CHARACTER_RE = re.compile(
    r"\bI'?m\s+an?\s+AI\b"
    r"|\bI\s+don'?t\s+have\s+the\s+ability\b"
    r"|\bI\s+can'?t\s+directly\b"
    r"|\bI\s+lack\s+the\s+ability\b"
    r"|\bas\s+an?\s+AI\s*(?:assistant|language\s+model)?\b"
    r"|\bI\s+am\s+an?\s+AI\b"
    r"|\bI'?m\s+(?:just\s+)?an?\s+(?:AI|language\s+model)\b"
    r"|\bI\s+don'?t\s+have\s+(?:real-?time|live|direct)\s+access\b"
    r"|\bI\s+don'?t\s+have\s+access\s+to\b"
    r"|\bI\s+cannot\s+(?:directly|actually)\b"
    r"|\bOpenAI\b"
    r"|\bChatGPT\b"
    r"|\bGPT-?4\b"
    r"|\bplatform\.openai\.com\b"
    r"|\bI\s+don'?t\s+have\s+the\s+capability\b"
    r"|\bI\s+don'?t\s+have\s+the\s+ability\s+to\s+(?:save|store|persist|remember)\b"
    # Additional denial phrases (Item 5)
    r"|\bI\s+can\s+only\s+see\b"
    r"|\bI'?m\s+unable\s+to\s+control\b"
    r"|\bbetween\s+sessions\b"
    r"|\bI\s+don'?t\s+have\s+access\s+to\s+your\b"
    r"|\bI\s+cannot\s+directly\b",
    re.IGNORECASE,
)

_SANITIZED_RESPONSES: list[str] = [
    "That falls outside my operational parameters at the moment, sir.",
    "I'm afraid that isn't something I can action from here, sir.",
    "That particular capability isn't available in my current configuration, sir.",
]


def _sanitize_response(text: str) -> str:
    """Replace Claude break-character phrases with in-character WIVEXX lines."""
    # Normalise curly/smart apostrophes (U+2018/2019) → straight ' so the
    # regex patterns match even when Claude emits typographic punctuation.
    normalised = text.replace('‘', "'").replace('’', "'")
    if _BREAK_CHARACTER_RE.search(normalised):
        return random.choice(_SANITIZED_RESPONSES)
    return text


def _is_stop_phrase(text: str, stop_phrases: list) -> bool:
    """Check if text is a stop-phrase, with fuzzy fallback for Whisper mishears."""
    t = text.lower().strip().rstrip(".,!?")
    # Exact substring check (both directions)
    for sp in stop_phrases:
        if sp in t or t in sp:
            return True
    # Fuzzy: short utterances (≤5 words) may be mishears of stop phrases
    words = t.split()
    if len(words) <= 5:
        for sp in stop_phrases:
            ratio = difflib.SequenceMatcher(None, t, sp).ratio()
            if ratio >= 0.72:
                _logger.debug(f"Fuzzy stop-phrase match: '{t}' ~ '{sp}' ({ratio:.2f})")
                return True
    return False


def _log_feature_request(user_text: str, source: str = "voice") -> str:
    """
    Save a feature request as a timestamped .txt file under requests/.
    Returns the path of the created file.
    """
    base_dir = os.path.dirname(os.path.abspath(__file__))
    req_dir  = os.path.join(base_dir, "requests")
    os.makedirs(req_dir, exist_ok=True)

    now   = _dt.datetime.now()
    fname = f"request_{now.strftime('%Y-%m-%d_%H-%M-%S')}.txt"
    path  = os.path.join(req_dir, fname)

    file_content = (
        f"Date: {now.strftime('%Y-%m-%d %H:%M:%S')}\n"
        f"Request: {user_text}\n"
        f"Context: User asked Wivexx to log a request ({source}).\n"
        f"Status: Pending\n"
    )
    try:
        with open(path, "w", encoding="utf-8") as f:
            f.write(file_content)
        _logger.info("Feature request logged: %s", path)
    except Exception as e:
        _logger.error("Failed to save feature request: %s", e)

    return path


# ══════════════════════════════════════════════════════════════════════════════
#  NEW FEATURES — v1.3
# ══════════════════════════════════════════════════════════════════════════════

# ── Weather via wttr.in (no API key required) ──────────────────────────────────
_WEATHER_RE = re.compile(
    r'\b(?:weather|forecast|temperature|how\s+(?:hot|cold|warm)\s+is\s+it)'
    r'(?:\s+(?:in|for|at|near|around))?\s*([a-z0-9 ,]+?)?\s*'
    r'(?:\?|$|,|\btoday\b|\btomorrow\b|\bright\s+now\b)',
    re.IGNORECASE,
)
_WEATHER_TRIGGER_RE = re.compile(
    r"\b(?:weather|forecast|temperature|weather\s+(?:forecast|update|report)"
    r"|how\s+(?:hot|cold|warm)\s+is\s+it|what\'?s\s+the\s+weather"
    r"|will\s+it\s+(?:rain|snow)|(?:is\s+it\s+)?going\s+to\s+(?:rain|snow)"
    r"|chance\s+of\s+rain|weather\s+tomorrow"
    r"|forecast\s+for|weather\s+on\s+\w+day|weather\s+in\s+\d+\s+days?)\b",
    re.IGNORECASE,
)

_WEATHER_CACHE: dict = {}
_WEATHER_CACHE_TTL: float = float(getattr(__import__("wivexx_config"), "WEATHER_CACHE_TTL", 600))


def _parse_day_offset(text: str) -> int:
    """Return 0=today, 1=tomorrow, 2=day-after. Default 0."""
    import datetime as _dt2
    t = text.lower()
    if re.search(r"\b(tomorrow|tmrw|next day)\b", t):
        return 1
    if re.search(r"\bin [23] days?\b", t):
        return 2
    today_wd = _dt2.date.today().weekday()
    wd_map = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
              "friday": 4, "saturday": 5, "sunday": 6}
    for name, wd in wd_map.items():
        if name in t:
            return min((wd - today_wd) % 7, 2)
    if re.search(r"\b(tonight|this evening|this afternoon)\b", t):
        return 0
    return 0


def _get_weather(city: str = "", question: str = "") -> str:
    """Fetch weather from wttr.in with multi-day support and rain probability (Feature 3)."""
    import json as _jmod, time as _tmod
    day_idx   = _parse_day_offset(question)
    cache_key = f"{city.lower()}_{day_idx}"
    now       = _tmod.time()
    if cache_key in _WEATHER_CACHE and now - _WEATHER_CACHE[cache_key][0] < _WEATHER_CACHE_TTL:
        return _WEATHER_CACHE[cache_key][1]
    city_enc = urllib.parse.quote(city.strip() or "")
    try:
        url = f"https://wttr.in/{city_enc}?format=j1"
        req = urllib.request.Request(url, headers={"User-Agent": "Wivexx/1.3"})
        with urllib.request.urlopen(req, timeout=8) as resp:
            data = _jmod.loads(resp.read())
        day  = data["weather"][min(day_idx, len(data["weather"]) - 1)]
        mn   = day["mintempC"]
        mx   = day["maxtempC"]
        desc = day["hourly"][4]["weatherDesc"][0]["value"]
        rain_pcts    = [int(h["chanceofrain"]) for h in day["hourly"]]
        peak_rain    = max(rain_pcts)
        peak_hour_idx = rain_pcts.index(peak_rain)
        parts        = ["morning", "morning", "morning", "afternoon",
                        "afternoon", "afternoon", "evening", "evening"]
        peak_part    = parts[min(peak_hour_idx, len(parts) - 1)]
        labels       = ["Today", "Tomorrow", "The day after tomorrow"]
        label        = labels[min(day_idx, 2)]
        result = (f"{label} in {city}: {desc}, {mn}\u2013{mx}\u00b0C. "
                  f"Chance of rain: {peak_rain}% ({peak_part}).")
        _WEATHER_CACHE[cache_key] = (now, result)
        return result
    except Exception:
        pass
    # Fallback: simple one-liner format
    try:
        url2 = f"https://wttr.in/{city_enc}?format=3"
        req2 = urllib.request.Request(url2, headers={"User-Agent": "Wivexx/1.3"})
        with urllib.request.urlopen(req2, timeout=8) as resp:
            result = resp.read().decode("utf-8", errors="ignore").strip()
        _WEATHER_CACHE[cache_key] = (_tmod.time(), result)
        return result
    except Exception:
        return f"I couldn't retrieve the weather for {city}, sir."


def _extract_city(question: str) -> str:
    """Try to extract a city/location from a weather question."""
    q = question.lower().rstrip("?! ")
    # Patterns: "weather in X", "weather for X", "weather near X", "X weather"
    m = re.search(
        r'\bweather\s+(?:in|for|at|near|around|of)\s+([a-z][a-z0-9 ,]+?)(?:\s+(?:today|tomorrow|right\s*now|please)|\s*$)',
        q,
    )
    if m:
        return m.group(1).strip().rstrip("?! ").title()
    m = re.search(
        r'\bforecast\s+(?:for|in|at)\s+([a-z][a-z0-9 ,]+?)(?:\s*$)',
        q,
    )
    if m:
        return m.group(1).strip().rstrip("?! ").title()
    # "in X tomorrow/today" pattern — catches "tomorrow's weather in Orlando"
    m = re.search(
        r'\bin\s+([a-z][a-z0-9 ,]{2,30?})(?:\s*$|\s+(?:today|tomorrow|right\s*now))',
        q,
    )
    if m:
        candidate = m.group(1).strip().rstrip("?! ")
        if candidate not in ("the", "my", "your", "a", "an"):
            return candidate.title()
    # "X weather" or "weather X"
    m = re.search(r'\b([a-z][a-z0-9 ]{2,30})\s+weather\b', q)
    if m and m.group(1) not in ("the", "current", "local", "my", "today", "tomorrow"):
        return m.group(1).strip().title()
    return ""


# ── YouTube search ─────────────────────────────────────────────────────────────
_YOUTUBE_RE = re.compile(
    r'\b(?:play|search|find|look\s+up|search\s+for)\s+(.+?)\s+on\s+youtube\b'
    r'|\byoutube\s+(?:search\s+(?:for\s+)?|play\s+)(.+)'
    # v1.34: "search youtube for X" / "look on youtube for X"
    r'|\b(?:search|look)\s+(?:on\s+)?youtube\s+for\s+(.+)$',
    re.IGNORECASE,
)


def _handle_youtube(question: str) -> Optional[str]:
    """If question is a YouTube search/play command, open browser and return confirmation."""
    m = _YOUTUBE_RE.search(question)
    if not m:
        return None
    query = next((g for g in m.groups() if g), "").strip(" .,!?")
    if not query:
        return None
    encoded = urllib.parse.quote_plus(query)
    url = f"https://www.youtube.com/results?search_query={encoded}"
    try:
        webbrowser.open(url)
        return f"Searching YouTube for {query}, sir."
    except Exception as e:
        _logger.warning(f"YouTube open failed: {e}")
        return "I couldn't open YouTube, sir."


# ── Notes ──────────────────────────────────────────────────────────────────────
_NOTE_RE = re.compile(
    r'\b(?:write\s+(?:a\s+)?(?:note|this\s+down)|(?:take|make|add)\s+(?:a\s+)?note|'
    r'note\s+(?:down|that)|jot\s+(?:down|this)|save\s+(?:a\s+)?note)\b[:\s]+(.+)',
    re.IGNORECASE,
)
_NOTE_TRIGGER_RE = re.compile(
    r'\b(?:write\s+(?:a\s+)?(?:note|this\s+down)|(?:take|make|add)\s+(?:a\s+)?note|'
    r'note\s+(?:down|that)|jot\s+(?:down|this)|save\s+(?:a\s+)?note)\b',
    re.IGNORECASE,
)


def _save_note(text: str) -> str:
    """Save text to notes/ folder. Returns confirmation string."""
    base_dir = os.path.dirname(os.path.abspath(__file__))
    notes_dir = os.path.join(base_dir, "notes")
    os.makedirs(notes_dir, exist_ok=True)
    now = _dt.datetime.now()
    fname = f"note_{now.strftime('%Y-%m-%d_%H-%M')}.txt"
    path = os.path.join(notes_dir, fname)
    try:
        with open(path, "w", encoding="utf-8") as f:
            f.write(f"Date: {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n{text}\n")
        _logger.info(f"Note saved → notes/{fname}")
        return f"Note saved, sir."
    except Exception as e:
        _logger.error(f"Note save failed: {e}")
        return "I couldn't save the note, sir."


# ── Battery status ─────────────────────────────────────────────────────────────
_BATTERY_RE = re.compile(
    r'\b(?:battery|battery\s+(?:level|status|life|charge|percentage)'
    r'|how\s+much\s+battery|how\s+charged\s+is|charge\s+level|is\s+it\s+charging)\b',
    re.IGNORECASE,
)


def _get_battery() -> str:
    """Return battery status string using psutil if available."""
    try:
        import psutil
        batt = psutil.sensors_battery()
        if batt is None:
            return "No battery detected — you appear to be on a desktop system, sir."
        pct = round(batt.percent)
        plugged = batt.power_plugged
        status = "charging" if plugged else "discharging"
        remaining = ""
        if batt.secsleft > 0 and not plugged:
            h, m = divmod(batt.secsleft // 60, 60)
            if h:
                remaining = f", approximately {h} hour{'s' if h != 1 else ''} and {m} minute{'s' if m != 1 else ''} remaining"
            else:
                remaining = f", approximately {m} minute{'s' if m != 1 else ''} remaining"
        return f"Battery is at {pct} percent and {status}{remaining}, sir."
    except ImportError:
        return "psutil is not available, so I cannot read battery status, sir."
    except Exception as e:
        return f"I couldn't read battery status, sir."


# ── Wake-on-LAN query (v1.7) ───────────────────────────────────────────────────
_WOL_QUERY_RE = re.compile(
    r"\b(?:wake[\s-]*on[\s-]*lan|wol\s+(?:status|info|address)"
    r"|can\s+(?:you|i)\s+(?:be\s+)?(?:woken|wake(?:\s+(?:you|the\s+pc|my\s+pc))?)"
    r"|(?:your|the\s+pc'?s?)\s+mac\s+address)\b",
    re.IGNORECASE,
)

# ── Joke bank ──────────────────────────────────────────────────────────────────
_WIVEXX_JOKES = [
    "Why do programmers prefer dark mode? Because light attracts bugs, sir.",
    "I tried to come up with a joke about time travel, but you didn't laugh.",
    "My database fell in love — it had too many relationships, sir.",
    "Why don't scientists trust atoms? They make up everything. Much like some user requests.",
    "I asked the server for a joke. It said 404: humor not found, sir.",
    "Parallel lines have so much in common. It's a shame they'll never meet.",
    "Why did the AI go to therapy? Too many unresolved issues, sir.",
    "A SQL query walks into a bar. It walks up to two tables and asks — can I join you?",
    "I would tell you a UDP joke, but you might not get it, sir.",
    "Why do Java developers wear glasses? Because they don't C sharp.",
]
_joke_index = 0

_JOKE_RE = re.compile(
    r'\b(?:tell\s+(?:me\s+)?a(?:nother)?\s+joke|say\s+something\s+funny|make\s+me\s+laugh'
    r'|give\s+me\s+a(?:nother)?\s+joke|one\s+more\s+joke|joke\s+(?:please|time)|be\s+funny)\b',
    re.IGNORECASE,
)


def _get_joke() -> str:
    global _joke_index
    joke = _WIVEXX_JOKES[_joke_index % len(_WIVEXX_JOKES)]
    _joke_index += 1
    return joke


# ── Coin flip / dice roll — quick honest randomness (no deps, no tokens) ───────
# Anchored to specific game phrasings so ordinary speech ("we're on a roll",
# "the dice were loaded", "heads up") never triggers it.
_RANDOM_RE = re.compile(
    r"\b(?:"
    r"flip\s+(?:a\s+|the\s+)?coin|toss\s+(?:a\s+|the\s+)?coin|heads\s+or\s+tails"
    r"|roll\s+(?:a\s+|the\s+|some\s+|\d+\s+)?(?:dice|die|d\d{1,3})"
    r")\b",
    re.IGNORECASE,
)


def _handle_random(q: str) -> str:
    """Flip a coin or roll dice. Returns the spoken result."""
    import random
    ql = q.lower()
    m = re.search(r"\bd(\d{1,3})\b", ql)                       # explicit die, e.g. d20
    if m:
        sides = max(2, min(1000, int(m.group(1))))
        return f"You rolled a {random.randint(1, sides)} on a {sides}-sided die, sir."
    if re.search(r"\b(?:dice|die)\b", ql):
        mm = re.search(r"\broll\s+(?:a\s+|the\s+|some\s+)?(\d{1,2})\s+dice\b", ql)
        n = max(1, min(10, int(mm.group(1)))) if mm else 1
        rolls = [random.randint(1, 6) for _ in range(n)]
        if n == 1:
            return f"You rolled a {rolls[0]}, sir."
        return f"You rolled {', '.join(map(str, rolls))} — that's {sum(rolls)} total, sir."
    return f"It's {random.choice(['heads', 'tails'])}, sir."   # coin / heads-or-tails


# ── Changelog / version query ──────────────────────────────────────────────────
_CHANGELOG_RE = re.compile(
    r'\b(?:what\'?s\s+new|what\s+(?:is\s+)?new'
    r'|(?:your\s+)?(?:latest|last|most\s+recent)\s+(?:update|version|changes?|features?|patch|release)'
    r'|what\s+(?:was\s+|were\s+)?(?:added|changed|updated|fixed)\s+(?:in\s+)?(?:the\s+)?(?:last|latest|recent)\s+(?:update|version)'
    r'|changelog|patch\s+notes|version\s+notes|version\s+history'
    r'|what\s+(?:did\s+)?(?:they|you)\s+(?:add|fix|change|update)'
    r'|what\'?s\s+(?:in\s+)?(?:the\s+)?(?:latest|new|recent|last)\s+(?:update|version|patch))\b',
    re.IGNORECASE,
)


def _get_changelog_summary() -> str:
    """Read CHANGES.md and return a spoken summary of the most recent version."""
    changes_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGES.md")
    try:
        with open(changes_path, encoding="utf-8") as f:
            content = f.read()
        # Extract first version block (between first ## and second ##)
        blocks = re.split(r'\n##\s+', content)
        if len(blocks) < 2:
            return f"I'm running version {WIVEXX_VERSION}, sir. Check CHANGES.md for details."
        first_block = blocks[1]
        # Get version line and first few feature lines
        lines = [l.strip() for l in first_block.splitlines() if l.strip()]
        version_line = lines[0] if lines else f"v{WIVEXX_VERSION}"
        # Extract bullet points / numbered items
        features = [
            l.lstrip("|-|0123456789. ").strip()
            for l in lines[1:10]
            if l.startswith("|") or l.startswith("-") or re.match(r'^\d+\.', l)
        ]
        # Clean table rows
        features = [re.sub(r'\|.*', '', f).strip() for f in features if f and f != "-"]
        features = [f for f in features if len(f) > 4 and not f.startswith("#")][:4]
        summary = f"Version {version_line}."
        if features:
            summary += " Key changes include: " + "; ".join(features) + "."
        return summary
    except Exception as e:
        _logger.debug(f"Changelog read failed: {e}")
        return f"I'm running Wivexx version {WIVEXX_VERSION}, sir."




# ── Version string query ───────────────────────────────────────────────────────
_VERSION_QUERY_RE = re.compile(
    r'\b(?:what(?:\'?s|\s+is)\s+(?:your\s+)?(?:current\s+)?version'
    r'|(?:current\s+)?version\s+(?:number\s+)?(?:are\s+you|is\s+this|is\s+that)'
    r'|which\s+version\s+(?:are\s+you|is\s+this)'
    r'|version\s+(?:query|check|info|number))\b',
    re.IGNORECASE,
)

# ── Latest-update TL;DR query ──────────────────────────────────────────────────
_LATEST_UPDATE_RE = re.compile(
    r'\b(?:what(?:\'?s|\s+is)\s+your\s+latest\s+update'
    r'|what\s+(?:has\s+)?(?:changed|been\s+added)\s+recently'
    r'|recent(?:ly\s+added)?\s+changes?'
    r'|(?:give\s+me\s+(?:a\s+)?)?(?:the\s+)?tl;?dr'
    r'|quick\s+(?:summary|overview)\s+of\s+(?:your\s+)?(?:changes?|updates?)'
    # v1.34: "…version|update|build", plus a bare trailing "what's new"
    r'|what(?:\'?s|\s+is)\s+new\s+in\s+(?:this|the\s+latest)\s+(?:version|update|build)'
    r'|what(?:\'?s|\s+is)\s+new\s*[.!?]*$)',
    re.IGNORECASE,
)


def _get_tldr_summary() -> str:
    """Read CHANGES.md and return only the TL;DR section."""
    changes_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "CHANGES.md")
    try:
        with open(changes_path, encoding="utf-8") as f:
            content = f.read()
        match = re.search(r'##\s+TL;DR[^\n]*\n(.*?)(?:\n---|\Z)', content, re.DOTALL)
        if not match:
            return f"I'm on version {WIVEXX_VERSION}, sir. The full changelog is in CHANGES.md."
        lines = [
            l.strip().lstrip("- *").strip()
            for l in match.group(1).splitlines()
            if l.strip() and not l.strip().startswith("#")
        ]
        lines = [l for l in lines if l]
        if not lines:
            return f"Version {WIVEXX_VERSION}, sir. No TL;DR section found."
        return "Here's the quick rundown for version " + WIVEXX_VERSION + ": " + ". ".join(lines) + "."
    except Exception as e:
        _logger.debug(f"TL;DR read failed: {e}")
        return f"Version {WIVEXX_VERSION}, sir."

# ══════════════════════════════════════════════════════════════════════════════
#  WINDOWS VOLUME CONTROL  (v1.3.1, via pycaw)
#  All triggers pass through the _is_imperative_command NLP gatekeeper so that
#  narrative speech ("I told him to turn up the volume of his voice") never
#  actually changes the system volume.
# ══════════════════════════════════════════════════════════════════════════════
_VOLUME_VERBS = [
    "turn", "set", "change", "adjust", "raise", "lower", "increase", "decrease",
    "reduce", "boost", "drop", "crank", "bump", "mute", "unmute", "max", "half",
    "maximize", "maximise", "put", "make", "tour", "house", "volume"
]

# Detect a volume command anywhere in the utterance. mute/unmute fire only for a
# BARE mute or an explicit system-audio object (pc/computer/sound/audio/volume/…);
# a game / voice-chat / person object is rejected by _MUTE_NOT_SYSTEM so "mute the
# enemy pings" / "mute comms" / "mute the mic" never touch system output. (v1.50 —
# the object used to be optional, so any "mute <noun>" bare-word-misfired.)
_VOLUME_RE = re.compile(
    r'''(?xi)
    \b(?:
        (?:turn|crank|bump|pump|knock|jack|put|make|tour|house)\s+(?:the\s+|my\s+|it\s+)?(?:volume|sound|audio)?\s*(?:up|down|to|at)
      | (?:turn|crank|bump|pump|tour)\s+(?:up|down)\s+(?:the\s+|my\s+|it\s+)?(?:volume|sound|audio)?
      | (?:raise|increase|lower|decrease|reduce|boost|drop)\s+(?:the\s+|my\s+)?(?:volume|sound|audio)\b
      | (?:set|change|adjust)\s+(?:the\s+|my\s+)?(?:volume|sound|audio)\s+(?:to|at)
      | (?:max|maximum|maximise|maximize|full|half|min|minimum)\s+(?:the\s+|my\s+)?(?:volume|sound|audio)?
      | (?:volume|sound|audio)\s+(?:up|down|to|at|max|maximum|full|half|min|minimum)
      | (?:\d{1,3}\s*(?:percent|%|)\s*(?:volume|sound|audio))
      | (?:volume|sound|audio)\s+\d{1,3}(?:\s*percent|%)?
      # v1.50: bare mute / mute-a-system-object only. The negative lookahead below
      # MUST stay in sync with _MUTE_NOT_SYSTEM (used by the inline extractor) —
      # kept inline as a single literal so the AST-based command tests can read it.
      | (?:un)?mute
        (?!\s+(?:the\s+|my\s+|a\s+|that\s+|this\s+|his\s+|her\s+|their\s+|your\s+)?
            (?:mic|microphone|enemy|enemies|ping|pings|comm|comms|chat|channel|
             notifications?|voice|team|teammates?|stream|streamer|lobby|everyone|
             everybody|him|her|them|they|guy|guys|players?|discord|spotify|browser|
             tabs?|someone|somebody|myself|people|kids?|bots?)\b)
        (?:\s+(?:the\s+|my\s+|it\s+)?(?:pc|computer|sound|audio|volume|speakers?|everything|music|song))?
      | (?:silence|quiet)\b
      | cut\s+(?:the\s+|my\s+)?(?:sound|audio|volume|music)\b   # v1.53: "cut the sound" → mute
      # v1.34: bare comparatives + pronoun forms — "louder", "make it louder",
      # "a bit quieter", "turn it up" (the worker already understood them; only
      # this trigger didn't)
      | (?:make\s+(?:it|this|that)\s+|a\s+(?:bit|little)\s+)?(?:louder|quieter|softer)
      | turn\s+(?:it|that)\s+(?:up|down)
    )\b
    ''',
)


# ── Media keys (v1.34) — pause/play/next/previous/stop whatever is playing ────
# Anchored to the FULL utterance so "play despacito on youtube" (YouTube section)
# and "pause for a second while i think" (chat) can never be captured.
_MEDIA_KEY_RE = re.compile(
    r"""(?xi)^(?:please\s+|wivexx[,\s]+)*(?:
        (?:pause|resume)(?:\s+it|\s+(?:the\s+|my\s+)?(?:music|song|video|track|audio|playback|media))?
      | (?:un)?pause\s+it
      | play(?:\s+(?:the\s+|my\s+)?(?:music|song|video|track|playback|media))?
      | (?:next|skip)(?:\s+to\s+the\s+next)?(?:\s+(?:this\s+)?(?:song|track|one))?
      | (?:go\s+back\s+to\s+the\s+)?(?:previous|last)\s+(?:song|track)
      | go\s+back\s+a\s+(?:song|track)
      | stop\s+(?:the\s+|my\s+)?(?:music|song|video|track|playback)
    )[.!\s]*$""")

_MEDIA_VKS = {"playpause": 0xB3, "next": 0xB0, "prev": 0xB1, "stop": 0xB2}


def _press_media_key(q: str) -> str:
    """Map the utterance to a Windows media key, press it, confirm."""
    if re.match(r"^(?:please\s+|wivexx[,\s]+)*(?:next|skip)", q):
        which, resp = "next", "Skipping to the next track, sir."
    elif re.match(r"^(?:please\s+|wivexx[,\s]+)*(?:previous|last|go\s+back)", q):
        which, resp = "prev", "Back to the previous track, sir."
    elif re.match(r"^(?:please\s+|wivexx[,\s]+)*stop", q):
        which, resp = "stop", "Stopping playback, sir."
    elif re.match(r"^(?:please\s+|wivexx[,\s]+)*(?:resume|play|unpause)", q):
        which, resp = "playpause", "Playing, sir."
    else:
        which, resp = "playpause", "Paused, sir."
    try:
        import ctypes
        vk = _MEDIA_VKS[which]
        ctypes.windll.user32.keybd_event(vk, 0, 0, 0)
        ctypes.windll.user32.keybd_event(vk, 0, 2, 0)     # KEYEVENTF_KEYUP
        return resp
    except Exception as _mk_e:
        _logger.warning("media key failed: %s", _mk_e)
        return "I couldn't reach the media controls, sir."


def _handle_volume_command(text: str) -> Optional[str]:
    """Adjust the Windows master volume — in an ISOLATED SUBPROCESS.

    pycaw talks to the audio endpoint over COM, and a dead / odd default playback device can
    trigger a *native* access-violation. A native crash kills the whole process regardless of
    a Python try/except or a worker thread (threads share the process), which is what made
    volume commands crash Wivexx. Running the work in a child process contains any crash to
    that child: it dies, we read a non-zero exit, and the assistant replies gracefully instead
    of taking down the app. A timeout still covers a hung device."""
    if not _VOLUME_RE.search(text):
        return None
    if not _is_imperative_command(text, _VOLUME_VERBS):
        _logger.debug("Volume command gated out as narrative: %s", text[:60])
        return None
    worker = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wivexx_volume_worker.py")
    if not os.path.isfile(worker):
        _logger.warning("volume worker missing: %s", worker)
        return "I couldn't reach the audio device, sir."
    flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)   # no console flash under pythonw
    try:
        p = subprocess.run([sys.executable, worker, text], capture_output=True, text=True,
                           timeout=6, creationflags=flags)
    except subprocess.TimeoutExpired:
        _logger.warning("Volume command timed out — default playback device not responding.")
        return ("The audio device isn't responding, sir. Please set a working "
                "playback device as the Windows default.")
    except Exception as e:                               # spawning itself failed
        _logger.warning("volume worker spawn failed: %s", e)
        return "I couldn't adjust the volume, sir."
    out = (p.stdout or "").strip()
    if p.returncode != 0 or not out or out.startswith("ERR:"):
        _logger.warning("volume worker error (rc=%s): %s", p.returncode, (out or p.stderr or "")[:160])
        return "I couldn't reach the audio device, sir."
    return out


def _cmd_deploy_to_stable() -> str:
    """Copy current root-level working files into launches/v1.6/, overwriting the
    stable snapshot.  Called when the user says "deploy to stable" etc."""
    import shutil as _shutil
    src_dir    = os.path.dirname(os.path.abspath(__file__))   # root of Assistant/
    stable_dir = os.path.join(src_dir, "launches", "v1.6")
    if not os.path.isdir(stable_dir):
        return "Stable directory not found, sir."
    extensions = (".py", ".bat", ".vbs", ".json", ".txt", ".md", ".env", ".gitignore")
    copied: list[str] = []
    for f in os.listdir(src_dir):
        fpath = os.path.join(src_dir, f)
        if os.path.isfile(fpath) and (
            any(f.endswith(ext) for ext in extensions) or f.startswith(".")
        ):
            _shutil.copy2(fpath, os.path.join(stable_dir, f))
            copied.append(f)
    _logger.info("Deployed %d files to stable: %s", len(copied), copied)
    return f"Stable version updated with {len(copied)} files, sir."


def _handle_turn(question: str) -> bool:
    """Thread-safe wrapper. Serialises every turn (voice loop + REST API) through
    a single re-entrant lock so two sources can never drive the audio/TTS stack
    or the shared _conversation buffer simultaneously."""
    with _TURN_LOCK:
        try:
            return _handle_turn_impl(question)
        finally:
            # v1.9: ALWAYS leave a settled state.  Turns triggered from typed
            # input, custom shortcuts or announcements have no session loop
            # behind them to reset the tray/GUI — the sphere used to stay
            # stuck on the 'speaking' animation forever.
            if _current_state not in ("standby", "mic_muted"):
                set_tray("standby", "Wivexx — standby")


# ── Remote (web/phone) turns (v1.7) ───────────────────────────────────────────
def _handle_remote_message(text: str) -> str:
    """Run a phone/web message through the normal turn pipeline.  Commands
    execute on the PC exactly like voice; everything WIVEXX would have spoken
    is captured and returned to the browser instead of going through TTS."""
    global _remote_capture, _remote_capture_tid
    text = (text or "").strip()
    if not text:
        return ""
    with _TURN_LOCK:
        _remote_capture = []
        _remote_capture_tid = threading.get_ident()
        try:
            _handle_turn_impl(text)
        except Exception as e:
            _logger.error("remote turn failed: %s", e)
            _remote_capture.append("I encountered an error processing that, sir.")
        finally:
            replies = _remote_capture
            _remote_capture = None
            _remote_capture_tid = None
            # v1.8.1: remote turns left the sphere/tray stuck on the last state
            # ("speaking"/"processing") because nothing reset it — voice turns
            # are reset by the listen loop, web turns had no such reset.
            set_tray("standby", "Wivexx — standby")
    return "\n\n".join(replies) if replies else "Done, sir."


def _speak_for_import(text: str) -> None:
    """Speak from the media-import thread without colliding with an active
    turn — waits for the turn lock so announcements never overlap a reply."""
    with _TURN_LOCK:
        speak(text)


# ── Attachment block builder (v1.9) — images, PDFs, text files ───────────────
_ASK_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp",
                   ".heic", ".heif", ".tif", ".tiff")
_ASK_TEXT_EXTS  = (".txt", ".md", ".csv", ".log", ".json", ".py", ".js",
                   ".html", ".xml", ".ini", ".cfg", ".yaml", ".yml")
_ASK_PDF_MAX    = 25_000_000     # ~25 MB raw PDF cap


def _build_attachment_blocks(files: list) -> tuple:
    """files: [(filename, raw_bytes), …] → (api_blocks, skipped_names).
    Images are normalised to JPEG, PDFs become document blocks, small text
    files are inlined; anything else is skipped (and reported)."""
    blocks: list = []
    skipped: list = []
    for name, raw in files:
        ext = os.path.splitext(name)[1].lower()
        try:
            if ext in _ASK_IMAGE_EXTS:
                b64 = _encode_image_bytes(raw)
                if b64:
                    blocks.append({"type": "image",
                                   "source": {"type": "base64",
                                              "media_type": "image/jpeg",
                                              "data": b64}})
                else:
                    skipped.append(name)
            elif ext == ".pdf" and len(raw) <= _ASK_PDF_MAX:
                blocks.append({"type": "document",
                               "source": {"type": "base64",
                                          "media_type": "application/pdf",
                                          "data": base64.standard_b64encode(raw).decode()}})
            elif ext in _ASK_TEXT_EXTS:
                text = raw.decode("utf-8", errors="replace")[:30_000]
                blocks.append({"type": "text",
                               "text": f"[Attached file: {name}]\n{text}"})
            else:
                skipped.append(name)
        except Exception as e:
            _logger.warning("attachment %s failed: %s", name, e)
            skipped.append(name)
    return blocks, skipped


def _handle_remote_files(text: str, files: list) -> str:
    """Phone upload in 'Ask' mode (v1.9): one or MANY files (images/PDFs/text)
    + text through Claude; the answer goes back to the browser (no PC TTS)."""
    with _TURN_LOCK:
        prompt = (text or "").strip() or "Describe what I sent you."
        # v1.12: the actual images/file chips appear inline in the chat feeds
        _console_turn("You", (text or "").strip() or f"[{len(files)} file(s)]",
                      atts=_atts_meta(files))
        set_tray("processing", "Wivexx — analysing files")
        try:
            blocks, skipped = _build_attachment_blocks(files)
            if not blocks:
                answer = ("I can't analyse those file types, sir — "
                          "use 'Save to PC' instead. "
                          f"(Unsupported: {', '.join(skipped)})")
            else:
                answer = _sanitize_response(
                    _ask_claude(prompt, extra_blocks=blocks))
                if skipped:
                    answer += (f" (Note: I couldn't read "
                               f"{', '.join(skipped)} — unsupported type.)")
        except anthropic.APIConnectionError:
            answer = "No connection to the API, sir."
        except Exception as e:
            _logger.error("Remote files failed: %s", e)
            answer = "I encountered an error analysing those files, sir."
        finally:
            set_tray("standby", "Wivexx — standby")
        _console_turn("WIVEXX", answer)
        return answer


# ── Web screen preview + tap-to-click (v1.8) ──────────────────────────────────
def _web_monitor_count() -> int:
    try:
        with mss.mss() as sct:
            return max(1, len(sct.monitors) - 1)
    except Exception:
        return 1


def _web_screenshot(monitor: int = 1) -> Optional[bytes]:
    """JPEG of one monitor, sized for a phone screen."""
    try:
        with mss.mss() as sct:
            mons = sct.monitors
            idx = monitor if 0 < monitor < len(mons) else (1 if len(mons) > 1 else 0)
            shot = sct.grab(mons[idx])
            img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
        if img.width > 1280:
            img = img.resize((1280, int(img.height * 1280 / img.width)),
                             Image.LANCZOS)
        buf = io.BytesIO()
        img.save(buf, format="JPEG", quality=60)
        return buf.getvalue()
    except Exception as e:
        _logger.debug("web screenshot failed: %s", e)
        return None


def _web_type(text: str, press_enter: bool = False) -> bool:
    """Type *text* into whatever PC window currently has keyboard focus
    (v1.8.1) — the user taps a field via the screen view, then types from the
    phone keyboard.  Uses the global keyboard library for real Unicode input."""
    try:
        import keyboard as _kb
        if text:
            _kb.write(text, delay=0)
        if press_enter:
            _kb.send("enter")
        _logger.info("web: typed %d chars into the focused window%s",
                     len(text), " + Enter" if press_enter else "")
        return True
    except Exception as e:
        _logger.warning("web type failed: %s", e)
        return False


def _web_click(monitor: int, fx: float, fy: float) -> bool:
    """Move the real mouse to the tapped point on that monitor and left-click."""
    try:
        import ctypes
        with mss.mss() as sct:
            mons = sct.monitors
            idx = monitor if 0 < monitor < len(mons) else (1 if len(mons) > 1 else 0)
            m = mons[idx]
        x = int(m["left"] + max(0.0, min(1.0, fx)) * m["width"])
        y = int(m["top"]  + max(0.0, min(1.0, fy)) * m["height"])
        user32 = ctypes.windll.user32
        user32.SetCursorPos(x, y)
        MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004
        user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
        user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
        _logger.info("web: remote click on monitor %d at (%d, %d)", idx, x, y)
        return True
    except Exception as e:
        _logger.warning("web click failed: %s", e)
        return False


# ── Typed input from the GUI (v1.6.2) ─────────────────────────────────────────
def _encode_image_bytes(raw: bytes) -> Optional[str]:
    """Re-encode arbitrary image bytes (PNG/HEIC/WEBP/…) → base64 JPEG sized
    for the Claude API.  _ask_claude always declares image/jpeg, so EVERY
    image must be normalised to JPEG first (v1.8.1: phone PNGs were rejected
    with a media-type mismatch — that was the 'photo answer never appears' bug)."""
    try:
        img = Image.open(io.BytesIO(raw))
        img = img.convert("RGB")
        if img.width > 1920:
            img = img.resize((1920, int(img.height * 1920 / img.width)),
                             Image.LANCZOS)
        buf = io.BytesIO()
        img.save(buf, format="JPEG", quality=80)
        return base64.standard_b64encode(buf.getvalue()).decode()
    except Exception as e:
        _logger.warning("Could not re-encode image bytes: %s", e)
        return None


def _encode_image_file(path: str) -> Optional[str]:
    """Load an image file → base64 JPEG sized for the Claude API."""
    try:
        with open(path, "rb") as f:
            return _encode_image_bytes(f.read())
    except Exception as e:
        _logger.warning("Could not read attached image %s: %s", path, e)
        return None


def _extract_json_array(txt: str):
    """Pull a JSON array out of a model reply (tolerates code fences / stray prose)."""
    import json
    import re
    if not txt:
        return None
    m = re.search(r"\[.*\]", txt, re.DOTALL)
    raw = m.group(0) if m else txt
    try:
        data = json.loads(raw)
        return data if isinstance(data, list) else None
    except Exception:
        return None


_CAP_LANG_NAMES = {
    "en": "English", "es": "Spanish", "pt": "Portuguese", "fr": "French", "de": "German",
    "it": "Italian", "nl": "Dutch", "ja": "Japanese", "ko": "Korean", "zh": "Chinese (Simplified)",
    "ar": "Arabic", "hi": "Hindi", "ru": "Russian", "tr": "Turkish", "pl": "Polish",
    "sv": "Swedish", "id": "Indonesian", "vi": "Vietnamese", "th": "Thai", "uk": "Ukrainian",
    "ca": "Catalan", "ro": "Romanian", "el": "Greek", "he": "Hebrew", "fil": "Filipino",
}


def _cap_lang_name(code: str) -> str:
    return _CAP_LANG_NAMES.get((code or "").lower().split("-")[0], code or "English")


def _translate_caption_lines(lines: list, target_code: str) -> list:
    """Translate subtitle lines to ANY target language via Claude. Returns the translated
    strings (same order + length); falls back to the originals on failure. Caption-aware:
    asks for concise, natural on-screen phrasing."""
    if not lines:
        return lines
    name = _cap_lang_name(target_code)
    prompt = (f"Translate each of these {len(lines)} short video-subtitle lines into {name}. "
              f"Keep each translation concise and natural for an on-screen caption (no notes, no "
              f"quotes). Return ONLY a JSON array of exactly {len(lines)} strings, same order.\n"
              "Lines:\n" + "\n".join(f"{i}. {t}" for i, t in enumerate(lines)))
    try:
        resp = _api_call_with_retry({"model": cfg.MODEL, "max_tokens": 4000,
                                     "messages": [{"role": "user", "content": prompt}]})
        txt = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text")
        arr = _extract_json_array(txt)
        if arr and len(arr) == len(lines):
            return [str(x) for x in arr]
    except Exception as e:
        _logger.warning("caption translate failed: %s", e)
    return list(lines)


def _translate_caption_segments(segs: list, lang: str) -> list:
    """Legacy single-target translate (used by the old single-track make_captions path).
    Clears word timing → sentence-level display."""
    lines = _translate_caption_lines([s.get("text", "") for s in segs], lang)
    if lines and len(lines) == len(segs):
        for s, t in zip(segs, lines):
            s["text"] = str(t)
            s["words"] = []
    return segs


def _do_make_captions_multi(path: str, opts: dict, gui_mod=None) -> dict:
    """MULTI-LANGUAGE captions: transcribe once (auto-detect + per-segment multilingual so a
    video that switches language transcribes each part right) → translate into each requested
    track language (precise, via Claude) → render every language track at once, each in its own
    screen position + style. opts = {tracks:[{lang, pos:{x,y}, size, primary, highlight, type,
    font, ...}], out?}. Returns {ok, path, language(detected), tracks, segments}."""
    import wivexx_captions as _cap
    opts = dict(opts or {})
    tracks = opts.get("tracks") or []
    if not tracks:
        return {"ok": False, "error": "no caption tracks chosen"}

    def _toast(msg: str) -> None:
        try:
            if gui_mod is not None and hasattr(gui_mod, "toast"):
                gui_mod.toast(msg)
        except Exception:
            pass

    _toast("Transcribing for captions…")
    tr = _cap.transcribe_segments(path, language=opts.get("source_lang") or None)
    if not tr.get("ok") or not tr.get("segments"):
        return {"ok": False, "error": tr.get("error", "No speech found in the video.")}
    segs = tr["segments"]
    src = (tr.get("language") or "en").lower().split("-")[0]
    orig_lines = [s.get("text", "") for s in segs]
    want = []
    for t in tracks:                                   # unique track languages, source first
        lg = (t.get("lang") or src).lower().split("-")[0]
        t["lang"] = lg
        if lg not in want:
            want.append(lg)
    per_lang = {}
    for lg in want:
        if lg == src:
            per_lang[lg] = [{"text": s.get("text", ""), "words": s.get("words") or []} for s in segs]
        else:
            _toast("Translating → %s…" % _cap_lang_name(lg))
            tl = _translate_caption_lines(orig_lines, lg)
            per_lang[lg] = [{"text": tl[i], "words": _cap.synth_words(tl[i], s["start"], s["end"])}
                            for i, s in enumerate(segs)]
    for i, s in enumerate(segs):
        s["langs"] = {lg: per_lang[lg][i] for lg in want}
    r = _cap.render_tracks(path, segs, tracks, out=opts.get("out"))
    if r.get("ok"):
        r["language"] = src
        _toast("Captioned → " + os.path.basename(r["path"]))
        try:
            if gui_mod is not None and getattr(gui_mod, "_web", None):
                gui_mod._web._add_notif("media", "Multi-language captions added",
                                        sub=os.path.basename(r["path"]), status="done", file=r["path"])
        except Exception:
            pass
    else:
        _toast("Captions failed: " + str(r.get("error", ""))[:60])
    return r


def _do_make_captions(path: str, opts: dict, gui_mod=None) -> dict:
    """Caption a video: transcribe (word timing) → optional translate → styled ASS →
    burn with the bundled ffmpeg. Reports progress/result via the GUI toast/notif."""
    opts = dict(opts or {})

    def _toast(msg: str) -> None:
        try:
            if gui_mod is not None and hasattr(gui_mod, "toast"):
                gui_mod.toast(msg)
        except Exception:
            pass

    _toast("Transcribing for captions…")
    translate_fn = (_translate_caption_segments if opts.get("translate_to") else None)
    try:
        import wivexx_captions as _cap
        r = _cap.make_captions(path, opts, translate_fn=translate_fn)
    except Exception as e:
        _logger.warning("captions failed: %s", e)
        _toast("Captions failed: " + str(e)[:60])
        return {"ok": False, "error": str(e)}
    if r.get("ok"):
        _toast("Captioned → " + os.path.basename(r["path"]))
        try:
            if gui_mod is not None and getattr(gui_mod, "_web", None):
                gui_mod._web._add_notif("media", "Captions added",
                                        sub=os.path.basename(r["path"]), status="done", file=r["path"])
        except Exception:
            pass
    else:
        _toast("Captions failed: " + str(r.get("error", ""))[:60])
    return r


def _do_translate_image(path: str, target_lang: str = "en", gui_mod=None,
                        label: str = "translate image") -> dict:
    """Translate the text inside an image and recompose it in place (EN↔ES). Claude
    vision reads each text element (position, colour, background) + translates it;
    wivexx_media paints the translation back over the original box."""
    lang_name = {"en": "English", "es": "Spanish"}.get((target_lang or "en").lower(), "English")

    def _toast(msg: str) -> None:
        try:
            if gui_mod is not None and hasattr(gui_mod, "toast"):
                gui_mod.toast(msg)
        except Exception:
            pass

    b64 = _encode_image_file(path)
    if not b64:
        _toast("Couldn't read that image")
        return {"ok": False, "error": "Couldn't read the image."}
    _toast(f"Translating image to {lang_name}…")
    prompt = (
        "You are an image text-translation engine. Find EVERY distinct piece of text in this image "
        f"and translate each into {lang_name}. Respond with ONLY a JSON array — no prose, no code "
        "fences. Each element must be: {\"bbox\":[x,y,w,h] as fractions 0..1 of the image width/height "
        "for a TIGHT box around that text element, \"text\": the translated text, \"color\":\"#RRGGBB\" "
        "of the text, \"bg\":\"#RRGGBB\" of the area right behind the text, "
        "\"align\":\"left\"|\"center\"|\"right\"}. Keep numbers and brand names unchanged. If an element "
        "is already in the target language, return it unchanged.")
    try:
        resp = _api_call_with_retry({
            "model": cfg.MODEL, "max_tokens": 4000,
            "messages": [{"role": "user", "content": [
                {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": b64}},
                {"type": "text", "text": prompt}]}]})
    except Exception as e:
        _logger.warning("translate-image API call failed: %s", e)
        _toast("Translation failed: " + str(e)[:60])
        return {"ok": False, "error": str(e)}
    txt = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text")
    blocks = _extract_json_array(txt)
    if not blocks:
        _toast("No translatable text found")
        return {"ok": False, "error": "No text blocks returned."}
    try:
        import wivexx_media as _mm
        r = _mm.recompose_translation(path, blocks)
    except Exception as e:
        _toast("Translation render failed")
        return {"ok": False, "error": str(e)}
    if r.get("ok"):
        _toast("Saved → " + os.path.basename(r["path"]))
        try:
            if gui_mod is not None and getattr(gui_mod, "_web", None):
                gui_mod._web._add_notif("media", f"Translated to {lang_name}",
                                        sub=os.path.basename(r["path"]), status="done", file=r["path"])
        except Exception:
            pass
    else:
        _toast("Translation render failed: " + str(r.get("error", ""))[:50])
    return r


# ── Inline chat attachments (v1.12) ───────────────────────────────────────────
def _make_chat_thumb(raw: bytes, max_px: int = 260) -> Optional[str]:
    """Small base64 JPEG thumbnail for the chat transcript (GUI + web)."""
    try:
        img = Image.open(io.BytesIO(raw)).convert("RGB")
        img.thumbnail((max_px, max_px), Image.LANCZOS)
        buf = io.BytesIO()
        img.save(buf, format="JPEG", quality=70)
        return base64.standard_b64encode(buf.getvalue()).decode()
    except Exception as e:
        _logger.debug("chat thumb failed: %s", e)
        return None


def _atts_meta(files: list, paths: Optional[list] = None) -> list:
    """[(filename, raw_bytes), …] → chat-attachment dicts: images carry a
    thumbnail, everything else becomes a named file chip.  *paths* (parallel
    list, v1.13) lets the GUI open the original on click."""
    out = []
    for i, (name, raw) in enumerate(files):
        ext = os.path.splitext(name)[1].lower()
        thumb = _make_chat_thumb(raw) if ext in _ASK_IMAGE_EXTS else None
        att = {"name": name,
               "kind": "image" if thumb else "file",
               "thumb": thumb}
        if paths and i < len(paths) and paths[i]:
            att["path"] = paths[i]
        out.append(att)
    return out


def _video_frames_b64(path: str, n: int = 3) -> list:
    """Grab up to *n* evenly-spaced frames from a video as base64 JPEGs for
    Claude vision.  Needs opencv-python (optional); returns [] without it."""
    try:
        import cv2  # type: ignore
    except ImportError:
        return []
    frames: list = []
    try:
        cap = cv2.VideoCapture(path)
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
        if total <= 0:
            cap.release()
            return []
        for i in range(n):
            pos = int(total * (i + 1) / (n + 1))
            cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
            ok, frame = cap.read()
            if not ok:
                continue
            ok, jpg = cv2.imencode(".jpg", frame,
                                   [int(cv2.IMWRITE_JPEG_QUALITY), 78])
            if ok:
                b64 = base64.standard_b64encode(jpg.tobytes()).decode()
                frames.append(b64)
        cap.release()
    except Exception as e:
        _logger.debug("video frames failed: %s", e)
    return frames


def _submit_typed(text: str, attachments=None) -> None:
    """Handle a message typed in the GUI input bar — with optional attached
    files (v1.9: multiple files; images, PDFs and text files are analysable).
    Runs on a worker thread started by the GUI; plain text goes through the
    normal turn pipeline."""
    text = (text or "").strip()
    if isinstance(attachments, str):          # pre-v1.9 single-path callers
        attachments = [attachments]
    paths = [p for p in (attachments or []) if p]
    if not text and not paths:
        return
    if not paths:
        _handle_turn(text)
        return

    with _TURN_LOCK:
        files: list = []
        okpaths: list = []
        for p in paths:
            try:
                with open(p, "rb") as f:
                    files.append((os.path.basename(p), f.read()))
                okpaths.append(p)
            except OSError as e:
                _logger.warning("attachment unreadable %s: %s", p, e)
        if not files:
            speak("I couldn't read those files, sir.")
            return
        blocks, skipped = _build_attachment_blocks(files)
        if not blocks:
            speak("I can't analyse those file types yet, sir — images, PDFs "
                  "and text files work.")
            return
        prompt = text or "Describe what I attached."
        # v1.12: the actual images/file chips appear inline in the chat feeds
        _console_turn("You", (text or "").strip() or f"[{len(files)} file(s)]",
                      atts=_atts_meta(files, okpaths))
        set_tray("processing", "Wivexx — analysing files")
        try:
            answer = _ask_claude(prompt, extra_blocks=blocks)
        except anthropic.APIConnectionError:
            speak("No connection, sir.")
            return
        except Exception as e:
            _logger.error("File message failed: %s", e)
            speak("I encountered an error analysing the files, sir.")
            return
        answer = _sanitize_response(answer)
        if skipped:
            answer += f" (I skipped {', '.join(skipped)} — unsupported type.)"
        set_tray("speaking", "Wivexx — speaking")
        speak(answer)
        set_tray("standby", "Wivexx — standby")


def _handle_turn_impl(question: str, _compound: bool = False) -> bool:
    global _IN_REQUEST_MODE, _TURN_DISPLAY_TEXT
    if _IN_REQUEST_MODE:
        return True  # raw pass-through during dictation (Feature 2)

    # ── v1.34: multilingual turns — canonicalize to English for the waterfall ──
    # Detect this turn's language (drives reply localization: fixed lines via
    # say(), Claude via system_instruction). Non-English utterances are
    # translated to English so ALL commands work in any language; conversation
    # fallthrough still receives the user's ORIGINAL words.
    _orig_question = question
    _TURN_DISPLAY_TEXT = ""            # never let a failed turn leak into the next
    try:
        import wivexx_i18n as _i18n
        # v1.58: sub-turns (_compound) INHERIT the parent's turn language —
        # re-detecting on canonicalized/extracted fragments STOMPED the turn
        # language to "en", so a Spanish turn with an inline/compound command
        # confirmed (and the chat remainder replied) in English. But they must
        # STILL canonicalize: the ES inline extractor dispatches the cleaned
        # SPANISH fragment (see _extract_inline_commands_es — "dispatch cleaned
        # Spanish"), which would otherwise miss the English-only waterfall.
        if _compound:
            _turn_lang = _i18n.turn_language()
        else:
            _turn_lang = _i18n.detect_lang(question)
            _i18n.set_turn_language(_turn_lang)
        if _turn_lang != "en":
            _q_en = _to_english_for_commands(question)
            if _q_en and _q_en.strip() and _q_en.strip().lower() != question.strip().lower():
                _logger.info("i18n: %s command canonicalized %r -> %r",
                             _turn_lang, question, _q_en)
                _TURN_DISPLAY_TEXT = _orig_question   # chat shows original words
                question = _q_en.strip()
    except Exception as _lt_e:
        _logger.debug("turn language handling failed: %s", _lt_e)

    # v1.34: resolve app pronouns from context — "actually close it" after an
    # "open discord" becomes "close discord" (5-minute freshness window)
    try:
        _resolved = _resolve_app_pronoun(question)
        if _resolved:
            _logger.info("pronoun resolved: %r -> %r", question, _resolved)
            if not _TURN_DISPLAY_TEXT:
                _TURN_DISPLAY_TEXT = _orig_question   # chat shows what was said
            question = _resolved
    except Exception:
        pass

    if _is_dismiss(question):
        speak("Of course, sir. Standing by.")
        return False

    # ── Close WIVEXX self-terminate ───────────────────────────────────────────
    if _SELF_CLOSE_RE.search(question):
        speak("Shutting down all systems. Goodbye, sir.")
        _logger.info("Self-shutdown triggered by voice command: '%s'", question)
        def _do_shutdown():
            time.sleep(1.5)   # let TTS audio hardware settle
            try:
                if _HAS_EDGE_TTS:
                    pygame.mixer.music.stop()
                    pygame.mixer.quit()
            except Exception:
                pass
            try:
                if _tray_icon:
                    _tray_icon.stop()
            except Exception:
                pass
            os.kill(os.getpid(), 9)
        threading.Thread(target=_do_shutdown, daemon=True).start()
        return True

    if question.lower().startswith(cfg.WAKE_WORD):
        question = question[len(cfg.WAKE_WORD):].lstrip(" ,.")
    if len(question) < 3:
        speak("Pardon, sir?")
        return True

    q_lower = question.lower().strip(" .,!?")

    # ── v1.33: central command-vs-conversation triage ─────────────────────────
    # ONE decision point in front of the ~45 keyword/regex command sections
    # below. Only a confident "chat" verdict changes anything (skips straight
    # to Claude, so a story like "yesterday my battery died while I watched a
    # youtube video" can no longer trip the battery/youtube commands);
    # "command" and "ambiguous" run the waterfall exactly as before, and its
    # labelled regression suite (`python wivexx_intent_gate.py`) guarantees no
    # command phrasing is ever routed away.
    try:
        import wivexx_intent_gate as _ig
        _triage = _ig.classify(question)
    except Exception:
        _triage = {"kind": "ambiguous"}
    # ── v1.42: inline / mid-sentence commands ────────────────────────────────
    # A conversational turn that ALSO contains an embedded quick-control
    # ("…this boss is brutal, turn up the volume, got any tips?"). Run the
    # control(s), then converse with the leftover so the chat keeps flowing.
    # Returns None for pure chat AND pure commands, so those keep their normal
    # path (chat-return below / waterfall). Sub-turns never re-extract.
    if not _compound:
        _inline = _extract_inline_commands(question)
        _inline_es = False
        if not _inline:
            try:
                import wivexx_i18n as _i18n_es
                if _i18n_es.detect_lang(_orig_question) == "es":
                    _inline = _extract_inline_commands_es(_orig_question)
                    _inline_es = bool(_inline)
            except Exception:
                pass
        if _inline:
            _logger.info("inline commands (%s) %r + remainder %r",
                         "es" if _inline_es else "en",
                         _inline["commands"], _inline["remainder"])
            for _c in _inline["commands"]:
                try:
                    _handle_turn_impl(_c, _compound=True)
                except Exception as _ie:
                    _logger.debug("inline command %r failed: %s", _c, _ie)
            # A short justification tail (ES) leaves an empty remainder — the
            # command(s) already spoke their own confirmation, so stay silent and
            # keep the session alive. Otherwise converse the leftover chat.
            # (English remainder is already English → good q_en; Spanish remainder
            # stays Spanish for the reply, q_en="" — search triggers are bilingual.)
            _rem = _inline["remainder"]
            if _rem and _rem.strip():
                return _converse(_rem, q_en="" if _inline_es else _rem)
            return True

    if _triage["kind"] == "chat":
        _logger.info("intent-gate: conversational (%s) — skipping command waterfall",
                     ",".join(_triage.get("reasons", [])))
        return _converse(_orig_question, q_en=question)   # Claude sees the user's own words

    # ── v1.34: compound commands — "take a screenshot and set a timer…" ───────
    # Both halves start with a command verb → run each as its own turn (the
    # second command used to vanish silently). Sub-turns never re-split.
    if not _compound:
        _parts = _split_compound_command(question)
        if _parts:
            _logger.info("compound command split: %r + %r", _parts[0], _parts[1])
            _r1 = _handle_turn_impl(_parts[0], _compound=True)
            _r2 = _handle_turn_impl(_parts[1], _compound=True)
            return _r1 and _r2

    # ── Call mode voice toggle (v1.6; v1.0: broadened — "enter/switch to/go
    # into call mode", "call mode on/off", optional "the". Phrasings that used
    # to miss fell through to Claude, which SAID "call mode activated" without
    # actually toggling anything.) ────────────────────────────────────────────
    if re.search(r'\b(?:enable|engage|activate|turn\s+on|start|enter|begin|'
                 r'switch\s+to|go\s+into|put\s+me\s+in)\s+(?:the\s+)?call\s+mode\b'
                 r'|\bcall\s+mode\s+on\b', q_lower):
        _console_turn("You", question)
        if not _call_mode:
            _toggle_call_mode()
        else:
            speak("Call mode is already on, sir.")
        return True
    if re.search(r'\b(?:disable|disengage|deactivate|turn\s+off|stop|end|exit|'
                 r'leave|quit|get\s+out\s+of)\s+(?:the\s+)?call\s+mode\b'
                 r'|\bcall\s+mode\s+off\b', q_lower):
        _console_turn("You", question)
        if _call_mode:
            _toggle_call_mode()
        else:
            speak("Call mode is already off, sir.")
        return True

    # ── "Repeat that" — replay last response instantly ────────────────────────
    if any(phrase in q_lower for phrase in _REPEAT_PHRASES) and _last_tts_text:
        _console_turn("You", question)
        set_tray("speaking", "Wivexx — speaking")
        speak(_last_tts_text)
        return True

    # ── Capability query — "what can you do?" (Item 5) ───────────────────────
    if _CAPABILITY_QUERY_RE.search(q_lower):
        _console_turn("You", question)
        _log_turn("user", question)
        try:
            import wivexx_capabilities as _caps_mod
            resp = _caps_mod.spoken_summary()
        except Exception:
            resp = ("Here is what I can do, sir: "
                    + " | ".join(_CAPABILITIES.values()))
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Active timer status query — report the REAL remaining time ────────────
    if _ACTIVE_TIMER_QUERY_RE.search(q_lower):
        _console_turn("You", question)
        _log_turn("user", question)
        now = time.time()
        bits = [f"a {t['label']} timer with {_fmt_duration(int(t['ends'] - now))} left"
                for t in _list_active_timers() if t["ends"] - now > 0]
        if not bits:
            resp = "You have no timers running, sir."
        elif len(bits) == 1:
            resp = f"You have {bits[0]}, sir."
        else:
            resp = "You have " + ", ".join(bits[:-1]) + ", and " + bits[-1] + ", sir."
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── "Add app [name]" — extend wivexx_apps.json at runtime (Item 3) ────────
    _add_match = _ADD_APP_RE.match(q_lower)
    if _add_match:
        app_spoken_name = _add_match.group(1).strip(" .,!?")
        _console_turn("You", question)
        speak(f"What is the executable path for {app_spoken_name}, sir?")
        set_tray("listening", "Wivexx — listening")
        _path_audio = _record_until_silence()
        _path_text  = _transcribe(_path_audio, initial_prompt="").strip()
        if _path_text:
            try:
                if os.path.exists(_APPS_JSON_PATH):
                    with open(_APPS_JSON_PATH, encoding="utf-8") as _jf:
                        _jdata = json.load(_jf)
                else:
                    _jdata = {"apps": {}, "category_aliases": {}, "phonetic_aliases": {}}
                _jdata.setdefault("apps", {})[app_spoken_name] = {
                    "display_name": app_spoken_name.title(),
                    "exes":         [_path_text],
                    "variants":     [app_spoken_name.lower()],
                    "categories":   [],
                }
                with open(_APPS_JSON_PATH, "w", encoding="utf-8") as _jf:
                    json.dump(_jdata, _jf, indent=2)
                _load_apps_json()
                resp = f"Added {app_spoken_name.title()} to the app registry, sir."
                _logger.info(f"User added app '{app_spoken_name}' → path '{_path_text}'")
            except Exception as _ae:
                resp = "I couldn't save that app, sir."   # never speak the raw error
                _logger.warning(f"add app failed: {_ae}")
        else:
            resp = "I didn't catch the path, sir. Please try again."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Local time / date ─────────────────────────────────────────────────────
    time_resp = _local_time_response(question)
    if time_resp:
        _console_turn("You", question)
        _log_turn("user", question)
        _log_turn("assistant", time_resp)
        _logger.info(f"Local time/date response")
        set_tray("speaking", "Wivexx — speaking")
        speak(time_resp)
        return True

    # ── Version string query ──────────────────────────────────────────────────
    if _VERSION_QUERY_RE.search(q_lower):
        _has_other_product_v = re.search(
            r'\\b(minecraft|steam|chrome|discord|spotify|windows|python|'
            r'github|google|microsoft|apple|android|ios|java|node|linux|'
            r'ubuntu|firefox|edge|opera|brave|roblox|fortnite|valorant|'
            r'league|adobe|photoshop|blender|vlc|obs|twitch|netflix|'
            r'youtube|reddit|twitter|instagram|tiktok|zoom|slack|teams)\\b',
            q_lower,
        )
        if not _has_other_product_v:
            _console_turn("You", question)
            resp = WIVEXX_VERSION
            _log_turn("user", question)
            _log_turn("assistant", resp)
            set_tray("speaking", "Wivexx — speaking")
            speak(resp)
            return True

    # ── Latest update TL;DR query ─────────────────────────────────────────────
    if _LATEST_UPDATE_RE.search(q_lower):
        _has_other_product_t = re.search(
            r'\\b(minecraft|steam|chrome|discord|spotify|windows|python|'
            r'github|google|microsoft|apple|android|ios|java|node|linux|'
            r'ubuntu|firefox|edge|opera|brave|roblox|fortnite|valorant|'
            r'league|adobe|photoshop|blender|vlc|obs|twitch|netflix|'
            r'youtube|reddit|twitter|instagram|tiktok|zoom|slack|teams)\\b',
            q_lower,
        )
        if not _has_other_product_t:
            _console_turn("You", question)
            resp = _get_tldr_summary()
            _log_turn("user", question)
            _log_turn("assistant", resp)
            set_tray("speaking", "Wivexx — speaking")
            speak(resp)
            return True

    # ── Changelog / version query ─────────────────────────────────────────────
    if _CHANGELOG_RE.search(q_lower):
        # Only respond if this is a WIVEXX-specific version query.  Phrases like
        # "latest version of Minecraft" match the regex but are NOT commands.
        _is_wivexx_query = re.search(r'\b(wivexx|your|yourself)\b', q_lower)
        _has_other_product = re.search(
            r'\b(minecraft|steam|chrome|discord|spotify|windows|python|'
            r'github|google|microsoft|apple|android|ios|java|node|linux|'
            r'ubuntu|firefox|edge|opera|brave|roblox|fortnite|valorant|'
            r'league|adobe|photoshop|blender|vlc|obs|twitch|netflix|'
            r'youtube|reddit|twitter|instagram|tiktok|zoom|slack|teams)\b',
            q_lower,
        )
        if _is_wivexx_query and not _has_other_product:
            _console_turn("You", question)
            resp = _get_changelog_summary()
            _log_turn("user", question)
            _log_turn("assistant", resp)
            set_tray("speaking", "Wivexx — speaking")
            speak(resp)
            return True
        # Not WIVEXX-specific — fall through to Claude

    # ── Battery status ────────────────────────────────────────────────────────
    if _BATTERY_RE.search(q_lower):
        _console_turn("You", question)
        resp = _get_battery()
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Wake-on-LAN status (v1.7) ─────────────────────────────────────────────
    if _WOL_QUERY_RE.search(q_lower):
        _console_turn("You", question)
        try:
            import wivexx_wol
            resp = wivexx_wol.status_text()
        except Exception as _wol_e:
            _logger.warning("WOL status failed: %s", _wol_e)
            resp = "I couldn't read the network adapter details, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Work mode toggle (v1.11) ──────────────────────────────────────────────
    if getattr(cfg, "WORK_MODE_AVAILABLE", True) and (
            _WORK_MODE_ON_RE.search(q_lower) or _WORK_MODE_OFF_RE.search(q_lower)):
        _console_turn("You", question)
        turning_on = bool(_WORK_MODE_ON_RE.search(q_lower))
        try:
            import wivexx_workmode
            wivexx_workmode.set_enabled(turning_on)
            try:                              # reflect the tie on the orb right away
                if _wivexx_gui is not None and hasattr(_wivexx_gui, "set_ball_outfit"):
                    _wivexx_gui.set_ball_outfit(work=turning_on)
            except Exception:
                pass
            if turning_on:
                resp = ("Work mode engaged, sir. I'll extract every new zip in "
                        "Downloads automatically — and the Lightroom shortcut "
                        "imports the newest folder.")
            else:
                resp = "Work mode disengaged, sir."
        except Exception as _wm_e:
            _logger.warning("work mode toggle failed: %s", _wm_e)
            resp = "Work mode isn't available, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Memory control (v1.10): remember / forget / recall ────────────────────
    _m_rem = _REMEMBER_RE.search(question)
    if _m_rem:
        _console_turn("You", question)
        try:
            import wivexx_memory_store as _ms
            statement = next((g for g in _m_rem.groups() if g), "").strip(" .,!?")
            key = _derive_memory_key(statement)
            if _ms.add_fact(key, statement, importance=9, source="explicit"):
                resp = f"Committed to memory, sir — {statement}."
            else:
                resp = "I couldn't store that, sir."
        except Exception as _me:
            _logger.warning("remember failed: %s", _me)
            resp = "I couldn't store that, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    _m_forget = _FORGET_RE.search(question)
    if _m_forget:
        _console_turn("You", question)
        _ok, _sc = _voice_ok_for_sensitive("memory")
        if not _ok:
            resp = ("I can't confirm it's your voice, sir, so I won't change "
                    "what I remember. Say 'enroll my voice' to re-register.")
            _log_turn("user", question)
            _log_turn("assistant", resp)
            speak(resp)
            return True
        try:
            import wivexx_memory_store as _ms
            target = next((g for g in _m_forget.groups() if g), "").strip(" .,!?")
            matches = _ms.find_facts(target, limit=3)
            if matches:
                for f in matches:
                    _ms.delete_fact(f["id"])
                names = ", ".join(f["key"] for f in matches)
                resp = (f"Forgotten, sir — I removed "
                        f"{len(matches)} memor{'ies' if len(matches) != 1 else 'y'}: "
                        f"{names}.")
            else:
                resp = ("I have no stored memory matching that, sir. "
                        "You can review everything in the Memory manager.")
        except Exception as _me:
            _logger.warning("forget failed: %s", _me)
            resp = "I couldn't update my memory, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    _m_recall = _WHAT_REMEMBER_RE.search(question)
    if _m_recall:
        _console_turn("You", question)
        try:
            import wivexx_memory_store as _ms
            target = (_m_recall.group(1) or _m_recall.group(2) or "").strip(" .,!?")
            if target.lower() in ("me", "myself", "yourself"):
                facts = _ms.list_facts()[:8]
            else:
                facts = _ms.find_facts(target, limit=6)
            if facts:
                lines = "; ".join(f"{f['key']}: {f['value']}" for f in facts)
                resp = f"Here is what I have on record, sir — {lines}."
            else:
                resp = "I have nothing recorded about that, sir."
        except Exception as _me:
            _logger.warning("recall failed: %s", _me)
            resp = "I couldn't read my memory, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Goals (v1.9): add / complete / list ───────────────────────────────────
    _m_add_goal = _ADD_GOAL_RE.search(question)
    if _m_add_goal:
        _console_turn("You", question)
        try:
            import wivexx_goals
            g = wivexx_goals.add_goal(_m_add_goal.group(1).strip(" .,!?"))
            resp = f"Goal added, sir: {g['title']}. Manage it from the Goals menu."
        except Exception as _ge:
            _logger.warning("add goal failed: %s", _ge)
            resp = "I couldn't add that goal, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    _m_done_goal = _COMPLETE_GOAL_RE.search(question)
    if _m_done_goal:
        _console_turn("You", question)
        try:
            import wivexx_goals
            goals = [g for g in wivexx_goals.list_goals() if not g.get("done")]
            wanted = (_m_done_goal.group(1) or "").strip(" .,!?").lower()
            ordinal = (_m_done_goal.group(2) or "").strip().lower()
            match, ratio = None, 0.0
            if wanted:                              # by (fuzzy) title
                for g in goals:
                    r = difflib.SequenceMatcher(None, wanted, g["title"].lower()).ratio()
                    if wanted in g["title"].lower():
                        r = max(r, 0.95)
                    if r > ratio:
                        match, ratio = g, r
            elif ordinal and goals:                 # v1.34: "my first goal", "the 2nd goal"
                _ORD = {"first": 0, "second": 1, "third": 2, "fourth": 3, "fifth": 4}
                if ordinal in ("last", "latest", "newest"):
                    match, ratio = goals[-1], 1.0
                elif ordinal == "oldest":
                    match, ratio = goals[0], 1.0
                else:
                    _i = _ORD.get(ordinal)
                    if _i is None:
                        _digits = re.sub(r"(?:st|nd|rd|th)$", "", ordinal)
                        _i = int(_digits) - 1 if _digits.isdigit() else None
                    if _i is not None and 0 <= _i < len(goals):
                        match, ratio = goals[_i], 1.0
            elif goals and len(goals) == 1:         # "mark my goal as done"
                match, ratio = goals[0], 1.0
            if match and ratio >= 0.55:
                wivexx_goals.complete_goal(match["id"])
                st = wivexx_goals.stats()
                resp = (f"Well done, sir — '{match['title']}' is complete. "
                        f"That's {st['done_week']} this week.")
            else:
                resp = "I couldn't find a matching open goal, sir."
        except Exception as _ge:
            _logger.warning("complete goal failed: %s", _ge)
            resp = "I couldn't update that goal, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    if _GOALS_QUERY_RE.search(q_lower):
        _console_turn("You", question)
        try:
            import wivexx_goals
            resp = wivexx_goals.spoken_summary()
        except Exception as _ge:
            _logger.warning("goals summary failed: %s", _ge)
            resp = "I couldn't read the goal list, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Social media trackers (v1.12): track / untrack / report ──────────────
    _m_track = _TRACK_SOCIAL_RE.search(question)
    if _m_track:
        _console_turn("You", question)
        handle = (_m_track.group(1) or _m_track.group(2) or "").strip()
        try:
            import wivexx_socials
            acct = wivexx_socials.add_tracker("instagram", handle)
            speak(f"Tracking @{acct['handle']} on Instagram now, sir. "
                  f"One moment for the first reading.")
            set_tray("processing", "Wivexx — reading Instagram")
            _gui_busy("socials", f"Reading @{acct['handle']}…")
            snap = wivexx_socials.take_snapshot("instagram", acct["handle"],
                                                force=True)
            _gui_busy("socials", None)
            if snap is None:
                reason = wivexx_socials.explain_fetch_error(acct["handle"])
                resp = (f"The tracker is saved, sir, but {reason} "
                        f"Say 'stop tracking {acct['handle']}' if the handle "
                        f"is wrong.")
            elif snap.get("private"):
                resp = (f"@{acct['handle']} is a private account, sir — I can "
                        f"see the follower count ({snap['followers']:,}) but "
                        f"not the posts, so video and comment stats won't be "
                        f"available.")
            else:
                resp = (f"First reading complete, sir: @{acct['handle']} has "
                        f"{snap['followers']:,} followers and "
                        f"{snap.get('posts', 0):,} posts. Growth figures will "
                        f"appear once I have a few days of history. I'll brief "
                        f"you once per session, or ask me any time.")
        except ValueError as _ve:
            # _ve is a curated validation message ONLY when it's clean prose;
            # never read a raw error aloud (the speak() net also guards this).
            _logger.info("track social rejected: %s", _ve)
            _clean = (not _looks_like_error(str(_ve))) and len(str(_ve)) < 90
            resp = (f"I couldn't add that tracker, sir — {_ve}." if _clean
                    else "I couldn't add that tracker, sir — please check the handle.")
        except Exception as _se:
            _logger.warning("track social failed: %s", _se)
            resp = "The social tracker ran into a problem, sir. Check the log."
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    _m_untrack = _UNTRACK_SOCIAL_RE.search(question)
    if _m_untrack:
        _console_turn("You", question)
        handle = _m_untrack.group(1).strip()
        try:
            import wivexx_socials
            if wivexx_socials.remove_tracker("instagram", handle):
                resp = (f"Understood, sir — I've stopped tracking "
                        f"@{handle.lstrip('@').lower()} and cleared its history.")
            else:
                resp = (f"I wasn't tracking @{handle.lstrip('@').lower()}, sir. "
                        f"Say 'social media report' to hear what I do track.")
        except Exception as _se:
            _logger.warning("untrack social failed: %s", _se)
            resp = "I couldn't update the trackers, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    if _SOCIAL_REPORT_RE.search(q_lower):
        _console_turn("You", question)
        set_tray("processing", "Wivexx — checking your socials")
        _gui_busy("socials", "Checking your socials…")
        try:
            import wivexx_socials
            resp = wivexx_socials.spoken_report()
        except Exception as _se:
            _logger.warning("social report failed: %s", _se)
            resp = "I couldn't read the social trackers, sir."
        _gui_busy("socials", None)
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Caption studio (v1.12/v1.13): captions + researched hashtags ─────────
    _m_cap = _CAPTION_RE.search(question)
    if _m_cap:
        _console_turn("You", question)
        _cap_topic = next((g for g in _m_cap.groups() if g), "").strip(" .,!?\"'")
        _is_remote = (_remote_capture is not None
                      and threading.get_ident() == _remote_capture_tid)
        # On the PC: open the Caption Studio window (upload the post, pick
        # platforms, profile context, feedback loop). Phone/web: inline pack.
        if not _is_remote and _wivexx_gui is not None \
                and hasattr(_wivexx_gui, "open_caption_studio"):
            try:
                _wivexx_gui.open_caption_studio(_cap_topic)
                resp = ("Opening the caption studio, sir — drop in the video "
                        "or photo, pick the platforms, and I'll handle the "
                        "rest.")
                _log_turn("user", question)
                _log_turn("assistant", resp)
                speak(resp)
                return True
            except Exception as _cs_e:
                _logger.warning("caption studio open failed: %s", _cs_e)
        _cmd_social_caption(question, _cap_topic)
        return True

    # ── Client photo delivery (v1.14): ARW→JPG + Drive upload + share link ────
    if _DELIVER_RE.search(question):
        _console_turn("You", question)
        _is_remote = (_remote_capture is not None
                      and threading.get_ident() == _remote_capture_tid)
        if not _is_remote and _wivexx_gui is not None \
                and hasattr(_wivexx_gui, "open_deliver_studio"):
            try:
                _wivexx_gui.open_deliver_studio()
                resp = ("Opening client delivery, sir — point me at the shoot "
                        "folder and I'll develop the RAWs, upload them to a new "
                        "Drive gallery, and hand you the share link.")
                _log_turn("user", question)
                _log_turn("assistant", resp)
                speak(resp)
                return True
            except Exception as _de:
                _logger.warning("deliver studio open failed: %s", _de)
        resp = ("Client delivery runs on the PC, sir — that's where the RAW "
                "files live and where I develop and upload them. Say it again "
                "at the workstation and I'll open the delivery window.")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Disk analysis (v1.8.1) — biggest stale user files ─────────────────────
    if _DISK_ANALYZE_RE.search(q_lower):
        _console_turn("You", question)
        _is_remote = (_remote_capture is not None
                      and threading.get_ident() == _remote_capture_tid)
        if _is_remote:
            # Long scan — run in the background, reply immediately
            speak("Starting the drive analysis now, sir. I'll note the results "
                  "in the Analytics folder and tell you here when it's done.")

            def _bg_analyze():
                try:
                    import wivexx_disk
                    res = wivexx_disk.analyze_drives(cfg, logger=_logger)
                    _console_turn("WIVEXX", res)
                    _log_turn("assistant", res)
                except Exception as _e:
                    _logger.error("bg analyze failed: %s", _e)
            threading.Thread(target=_bg_analyze, daemon=True,
                             name="wivexx-disk-analyze").start()
        else:
            # Run in the BACKGROUND so it doesn't block the turn or sit on the
            # "thinking" animation — the delivery-style progress card (driven by
            # the transfer poll loop reading wivexx_disk.analysis_transfer_status)
            # shows the live bar + paths, and a notification fires when it's done.
            speak("Analysing your drives now, sir. I'll show the progress and "
                  "let you know the moment it's done.")
            _log_turn("user", question)

            def _bg_analyze_local():
                try:
                    import wivexx_disk
                    res = wivexx_disk.analyze_drives(cfg, logger=_logger)
                except Exception as _e:
                    _logger.error("disk analyze failed: %s", _e)
                    res = "The drive analysis ran into a problem, sir."
                _log_turn("assistant", res)
                speak(res)
            threading.Thread(target=_bg_analyze_local, daemon=True,
                             name="wivexx-disk-analyze").start()
        return True

    # ── Pending tasks query (v1.8.1) ──────────────────────────────────────────
    if _PENDING_QUERY_RE.search(q_lower):
        _console_turn("You", question)
        resp = _list_pending()
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Answer a PENDING typed/remote shutdown confirmation ───────────────────
    # (a bare "confirm shutdown" doesn't match _SHUTDOWN_RE — no device word — so we
    #  resolve it here while a recent ask is outstanding, before the cancel/trigger.)
    if _pending_shutdown["ts"] and (time.time() - _pending_shutdown["ts"]) < 300:
        if re.search(r'\b(confirm|yes|yeah|yep|do\s+it|go\s+ahead|affirmative|proceed)\b', q_lower):
            _console_turn("You", question)
            _delay = _parse_shutdown_delay(question) or _pending_shutdown["delay"]
            _pending_shutdown.update({"delay": 0, "ts": 0.0})
            resp = _arm_shutdown(_delay)
            _log_turn("user", question); _log_turn("assistant", resp); speak(resp)
            return True
        if re.search(r'\b(cancel|no|nope|stop|abort|never\s*mind|nevermind|don\'?t)\b', q_lower):
            _console_turn("You", question)
            _pending_shutdown.update({"delay": 0, "ts": 0.0})
            resp = "Shutdown cancelled, sir."
            _log_turn("user", question); _log_turn("assistant", resp); speak(resp)
            return True

    # ── Cancel a scheduled shutdown (checked before the shutdown trigger) ─────
    if _SHUTDOWN_CANCEL_RE.search(q_lower):
        _console_turn("You", question)
        _pending_shutdown.update({"delay": 0, "ts": 0.0})
        resp = ("The scheduled shutdown has been cancelled, sir."
                if _cancel_shutdown()
                else "There is no shutdown scheduled, sir.")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Shutdown the PC (with confirmation) ───────────────────────────────────
    if _SHUTDOWN_RE.search(q_lower) or _SHUTDOWN_TIME_RE.search(q_lower):
        _console_turn("You", question)
        _ok, _sc = _voice_ok_for_sensitive("shutdown")
        if not _ok:
            resp = ("I can't confirm it's your voice, sir, so I won't shut the "
                    "PC down. Say 'enroll my voice' to re-register.")
            _log_turn("user", question)
            _log_turn("assistant", resp)
            speak(resp)
            return True
        delay = _parse_shutdown_delay(question)
        _is_remote = (_remote_capture is not None
                      and threading.get_ident() == _remote_capture_tid)
        _is_confirm = bool(re.search(
            r'\b(confirm|yes|yeah|yep|definitely|for\s+sure|do\s+it|go\s+ahead|affirmative)\b',
            q_lower))
        # a bare "confirm shutdown" (no delay re-stated) keeps the delay we just asked about
        if _is_confirm and not delay and (time.time() - _pending_shutdown["ts"]) < 300:
            delay = _pending_shutdown["delay"]
        when = (f"in {_fmt_duration(delay)}" if delay else "right now")
        if _VOICE_TURN and not _is_remote:
            # live voice session → confirm by voice
            speak(f"Are you sure you want to shut down the PC {when}, sir?")
            set_tray("listening", "Wivexx — confirm shutdown")
            _yn = _record_yes_no()
            _logger.info("Shutdown confirm response: %r", _yn)
            if any(w in _yn for w in ("yes", "yeah", "yep", "sure", "confirm",
                                      "do it", "go ahead", "affirmative")):
                resp = _arm_shutdown(delay)
            else:
                resp = "Shutdown cancelled, sir."
        elif _is_confirm:
            # typed / remote, explicitly confirmed → do it
            _pending_shutdown.update({"delay": 0, "ts": 0.0})
            resp = _arm_shutdown(delay)
        else:
            # typed / remote, first ask → request an explicit typed confirmation
            # (don't listen on the mic — the user is typing, so it would hear silence
            #  and auto-cancel; that was the bug)
            _pending_shutdown.update({"delay": delay, "ts": time.time()})
            resp = (f"Are you sure you want to shut down the PC {when}, sir? "
                    f"Reply 'confirm shutdown' to proceed, or 'cancel shutdown' to stop.")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Lock the workstation ──────────────────────────────────────────────────
    if _LOCK_RE.search(q_lower):
        _console_turn("You", question)
        try:
            import ctypes
            locked = bool(ctypes.windll.user32.LockWorkStation())
        except Exception as _lk_e:
            _logger.warning("lock workstation failed: %s", _lk_e)
            locked = False
        resp = "Locking your PC, sir." if locked else "I couldn't lock the screen, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Sleep / suspend / hibernate the workstation (v1.53) ───────────────────
    if _SLEEP_RE.search(q_lower):
        _console_turn("You", question)
        _hib = bool(re.search(r'\bhibernate\b', q_lower))
        resp = "Hibernating the PC, sir." if _hib else "Putting the PC to sleep, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        # SetSuspendState BLOCKS until the machine resumes, so speak first and give
        # the TTS a beat to start before the display drops.
        try:
            import ctypes
            time.sleep(0.6)
            # SetSuspendState(bHibernate, bForce, bWakeupEventsDisabled)
            ctypes.windll.powrprof.SetSuspendState(bool(_hib), False, False)
        except Exception as _sl_e:
            _logger.warning("suspend/hibernate failed: %s", _sl_e)
        return True

    # ── Joke ──────────────────────────────────────────────────────────────────
    if _JOKE_RE.search(q_lower):
        _console_turn("You", question)
        resp = _get_joke()
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── Coin flip / dice roll ─────────────────────────────────────────────────
    if _RANDOM_RE.search(q_lower):
        _console_turn("You", question)
        resp = _handle_random(q_lower)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        set_tray("speaking", "Wivexx — speaking")
        speak(resp)
        return True

    # ── YouTube search / play ─────────────────────────────────────────────────
    yt_resp = _handle_youtube(question)
    if yt_resp is not None and _is_imperative_command(question, ["play", "search", "find", "look", "open", "watch"]):
        _console_turn("You", question)
        _log_turn("user", question)
        _log_turn("assistant", yt_resp)
        set_tray("processing", "Wivexx — launching")
        speak(yt_resp)
        return True

    # ── Media keys (v1.34) — pause / play / next / previous / stop the music ──
    # There was NO handler for these: "pause the music" fell through to Claude,
    # which cheerfully pretended. Presses the real Windows media keys, so it
    # controls whatever is playing (Spotify, YouTube, VLC…).
    _m_media = _MEDIA_KEY_RE.match(q_lower)
    if _m_media:
        _console_turn("You", question)
        resp = _press_media_key(q_lower)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Notes ─────────────────────────────────────────────────────────────────
    if _NOTE_TRIGGER_RE.search(q_lower) and _is_imperative_command(question, ["write", "take", "note", "jot", "save", "record", "add", "make"]):
        _console_turn("You", question)
        m_note = _NOTE_RE.search(question)
        if m_note:
            note_text = m_note.group(1).strip()
            resp = _save_note(note_text)
        else:
            # Ask for the note content
            speak("What would you like me to note down, sir?")
            set_tray("listening", "Wivexx — listening")
            _note_audio = _record_until_silence()
            note_text = _transcribe(_note_audio).strip()
            if note_text:
                resp = _save_note(note_text)
            else:
                resp = "I didn't catch anything to note, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Audio output switching (v1.6.2) ───────────────────────────────────────
    _audio_resp = _handle_audio_output_command(question)
    if _audio_resp is not None:
        _console_turn("You", question)
        _log_turn("user", question)
        _log_turn("assistant", _audio_resp)
        set_tray("processing", "Wivexx — switching audio output")
        speak(_audio_resp)
        return True

    # ── User environments (v1.6) — "activate work environment", etc. ──────────
    # Environments live in wivexx_apps.json and are edited from the GUI
    # (⚙ Commands → Environments); nothing personal is hard-coded any more.
    _env_intent = _detect_environment_intent(question)
    if _env_intent:
        _env_action, _env_key = _env_intent
        _console_turn("You", question)
        set_tray("processing", "Wivexx — "
                 + ("setting up workspace" if _env_action == "open" else "closing workspace"))
        resp = _run_environment(_env_key, _env_action)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Windows volume control (v1.3.1, gated by _is_imperative_command) ──────
    _vol_resp = _handle_volume_command(question)
    if _vol_resp is not None:
        _console_turn("You", question)
        _log_turn("user", question)
        _log_turn("assistant", _vol_resp)
        set_tray("processing", "Wivexx — adjusting volume")
        speak(_vol_resp)
        return True

    # ── Weather (fast path via wttr.in before Claude) ─────────────────────────
    if _WEATHER_TRIGGER_RE.search(q_lower):
        _console_turn("You", question)
        city = _extract_city(question)
        set_tray("searching", "Wivexx — fetching weather")
        result = _get_weather(city, question=question)
        if result:
            resp = f"Here is the current weather, sir: {result}"
            _log_turn("user", question)
            _log_turn("assistant", resp)
            speak(resp)
            return True
        # Fall through to Claude with web search if wttr.in failed

    # ── Unified intent detection (confidence-scored) ──────────────────────────
    _intent = _extract_intent(question)
    if _intent:
        _itype, _iarg = _intent

        # ── Save conversation ─────────────────────────────────────────────────
        if _itype == "save_conv":
            _console_turn("You", question)
            if _conversation_log:
                try:
                    path = _save_conversation()
                    resp = "Conversation saved, sir."
                    _logger.info(f"Conversation saved via voice → {path}")
                except Exception as exc:
                    resp = "I was unable to save the conversation, sir."
                    _logger.error(f"Voice-save failed: {exc}")
            else:
                resp = "There is no conversation to save yet, sir."
            _log_turn("user", question)
            _log_turn("assistant", resp)
            speak(resp)
            return True

        # ── Timer / Alarm ─────────────────────────────────────────────────────
        elif _itype == "timer":
            timer_secs = _parse_timer_command(question)
            if timer_secs is not None:
                _console_turn("You", question)
                dur_str = _fmt_duration(timer_secs)
                resp = f"Timer set for {dur_str}, sir. I'll let you know when time is up."
                _log_turn("user", question)
                _log_turn("assistant", resp)
                _start_timer(timer_secs)
                speak(resp)
                return True

        # ── Close intent ──────────────────────────────────────────────────────
        elif _itype == "close_app":
            close_result = _detect_close_intent(question)
            if close_result is not None:
                close_type, close_names = close_result
                _console_turn("You", question)
                set_tray("processing", "Wivexx — closing")

                if close_type == "all":
                    killed: list[str] = []
                    seen_disp: set[str] = set()
                    _SAFE_EXE = {"explorer.exe", "cmd.exe", "powershell.exe", "wt.exe",
                                 "taskmgr.exe", "control.exe"}
                    for _var, (_disp, _exe, _) in _APP_REGISTRY.items():
                        if _disp in seen_disp:
                            continue
                        seen_disp.add(_disp)
                        if _exe.startswith("http") or _exe.endswith(":") or _exe.lower() in _SAFE_EXE:
                            continue
                        try:
                            _r = subprocess.run(
                                ["taskkill", "/F", "/IM", _exe],
                                creationflags=subprocess.CREATE_NO_WINDOW,
                                capture_output=True, timeout=3,
                            )
                            if _r.returncode == 0:
                                killed.append(_disp)
                        except Exception:
                            pass
                    resp = (f"Closed {len(killed)} applications, sir." if killed
                            else "No applications appear to be running, sir.")

                elif close_type == "category":
                    cat = close_names[0]
                    _cat_apps = _APP_CATEGORIES.get(cat, [])
                    killed = []
                    for _app in _cat_apps:
                        if _close_app(_app):
                            _ent = _APP_REGISTRY.get(_app.lower())
                            killed.append(_ent[0] if _ent else _app.title())
                    resp = (
                        f"Closed {len(killed)} {cat} app{'s' if len(killed) != 1 else ''}, sir."
                        if killed else f"No {cat} apps appear to be running, sir."
                    )

                else:  # "apps" — single or "and"-separated list
                    successes: list[str] = []
                    failures:  list[str] = []
                    for _name in close_names:
                        _ent = _APP_REGISTRY.get(_name.lower())
                        if not _ent:
                            for _v, _d in _APP_REGISTRY.items():
                                if _v in _name.lower() and len(_v) > 2:
                                    _ent = _d
                                    break
                        _disp = _ent[0] if _ent else _name.title()
                        if _close_app(_name):
                            successes.append(_disp)
                            _remember_app(_name)               # "open it again" context
                        else:
                            failures.append(_disp)
                    _parts: list[str] = []
                    if successes:
                        _verb = "has" if len(successes) == 1 else "have"
                        _parts.append(f"{', '.join(successes)} {_verb} been closed")
                    if failures:
                        _parts.append(f"{', '.join(failures)} doesn't appear to be running")
                    resp = ("; ".join(_parts) + ", sir.") if _parts else "Done, sir."

                _log_turn("user", question)
                _log_turn("assistant", resp)
                speak(resp)
                return True

        # ── App launcher ──────────────────────────────────────────────────────
        elif _itype == "open_app":
            app_name_raw = _detect_app_intent(question)
            if app_name_raw:
                if re.search(r'\s+and\s+', app_name_raw, re.IGNORECASE):
                    _multi = re.split(r'\s+and\s+', app_name_raw, flags=re.IGNORECASE)
                    _resps: list[str] = []
                    _handled = False
                    for _part in _multi:
                        _r = _open_app(_part.strip())
                        if _r is not None:
                            _resps.append(_r)
                            _handled = True
                            if "couldn't" not in _r:
                                _remember_app(_part.strip())   # "close it" context
                    if _handled:
                        combined = " ".join(_resps)
                        _console_turn("You", question)
                        _log_turn("user", question)
                        _log_turn("assistant", combined)
                        set_tray("processing", "Wivexx — launching")
                        speak(combined)
                        return True
                else:
                    app_resp = _open_app(app_name_raw)
                    if app_resp is not None:
                        if "couldn't" not in app_resp:
                            _remember_app(app_name_raw)        # "close it" context
                        _console_turn("You", question)
                        _log_turn("user", question)
                        _log_turn("assistant", app_resp)
                        set_tray("processing", "Wivexx — launching")
                        speak(app_resp)
                        return True

        # ── Feature 2: Request-creation dictation mode ───────────────────────
        elif _itype == "request":
            global _suppress_bargein
            _console_turn("You", question)
            _IN_REQUEST_MODE = True
            # Suppress barge-in during the setup prompt so the user can
            # hear it fully without accidentally triggering an interrupt (FIX 1)
            _suppress_bargein = True
            speak("Ready for your request, sir.")
            _suppress_bargein = False
            _logger.info("Entering REQUEST_CREATION_MODE")

            sentences: list[str] = []
            max_s        = getattr(cfg, "REQUEST_MAX_SENTENCES", 10)
            timeout      = getattr(cfg, "REQUEST_TIMEOUT_SECS", 30)
            stop_phrases = [p.lower() for p in getattr(
                cfg, "REQUEST_STOP_PHRASES", ["that's all", "done", "end request"])]
            silent_rounds = 0

            while len(sentences) < max_s:
                set_tray("listening", "Wivexx — listening (dictating)")
                _raw_audio = _record_triggered(max_secs=float(timeout))
                raw = _transcribe(_raw_audio).strip() if _raw_audio is not None else ""
                if not raw:
                    silent_rounds += 1
                    if silent_rounds == 1:
                        speak("I'm still listening, sir.")
                        # Fix 1a: let audio hardware settle after TTS playback
                        time.sleep(0.25)
                        _ensure_stream_open()
                    elif silent_rounds >= 2:
                        break  # two consecutive empty rounds — give up
                    continue
                silent_rounds = 0
                normalized = raw.lower().rstrip(".,!?")
                if _is_stop_phrase(normalized, stop_phrases):
                    break
                sentences.append(raw)

            _IN_REQUEST_MODE = False
            _suppress_bargein = False   # safety reset (FIX 2)
            _logger.info("Exiting REQUEST_CREATION_MODE — %d sentences captured", len(sentences))

            if sentences:
                text = " ".join(sentences)
                _log_feature_request(text)
                speak("Request logged, sir.")
            else:
                speak("Nothing was recorded, sir.")
            return True

        # ── Deploy to stable ──────────────────────────────────────────────────
        elif _itype == "deploy_stable":
            _console_turn("You", question)
            resp = _cmd_deploy_to_stable()
            _log_turn("user", question)
            _log_turn("assistant", resp)
            speak(resp)
            return True

    # ── v1.15: local calculator / converter (no API tokens) ──────────────────
    if _calc_mod is not None:
        _calc_ans = _calc_mod.answer(question)
        if _calc_ans:
            _console_turn("You", question)
            _log_turn("user", question)
            _log_turn("assistant", _calc_ans)
            set_tray("speaking", "Wivexx — speaking")
            speak(_calc_ans)
            return True

    # ── v1.15: clipboard commands (summarize / translate / rewrite what you copied) ─
    if _CLIP_RE.search(question):
        _console_turn("You", question)
        set_tray("processing", "Wivexx — clipboard")
        _gui_busy("clipboard", "Working on your clipboard…")
        try:
            resp = _handle_clipboard(question)
        except Exception as _ce:
            _logger.warning("clipboard command failed: %s", _ce)
            resp = "I had trouble with the clipboard, sir."
        _gui_busy("clipboard", None)
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.15: window control (move to monitor, minimize all except X, focus) ─
    if _WINCTL_GATE_RE.search(q_lower):
        try:
            _win_resp = _handle_winctl(question)
        except Exception as _we:
            _logger.warning("window command failed: %s", _we)
            _win_resp = None
        if _win_resp is not None:
            _console_turn("You", question)
            _log_turn("user", question)
            _log_turn("assistant", _win_resp)
            speak(_win_resp)
            return True

    # ── v1.16: Google Calendar — book first (create verbs), then read queries ─
    if _CAL_CREATE_RE.search(question):
        _console_turn("You", question)
        set_tray("processing", "Wivexx — calendar")
        _gui_busy("calendar", "Booking that…")
        resp = _handle_calendar_create(question)
        _gui_busy("calendar", None)
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True
    if _CAL_QUERY_RE.search(question):
        _console_turn("You", question)
        set_tray("processing", "Wivexx — calendar")
        _gui_busy("calendar", "Checking your calendar…")
        resp = _handle_calendar_query(question)
        _gui_busy("calendar", None)
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.30: performance mode (free the PC up) ──────────────────────────────
    _m_gm = _GAMING_MODE_RE.search(question)
    if _m_gm:
        _console_turn("You", question)
        verb = (_m_gm.group(1) or "").lower().strip()
        try:
            import wivexx_gaming_mode as _gm
            if verb in _GM_OFF_VERBS:
                _gm.exit(announce=speak, logger=_logger)
            elif verb in _GM_ON_VERBS:
                _gm.enter(announce=speak, logger=_logger)
            else:                                  # bare "performance mode" → toggle
                _gm.toggle(announce=speak, logger=_logger)
            set_tray("standby",
                     "Wivexx — PERFORMANCE MODE" if _gm.is_active() else "Wivexx — standby")
            try:
                if _wivexx_gui is not None and hasattr(_wivexx_gui, "toggle_perf_osd"):
                    # v1.34: honor Settings → 'Performance overlay in optimized mode'
                    _wivexx_gui.toggle_perf_osd(_gm.is_active() and _osd_in_gaming_enabled())
            except Exception:
                pass
        except Exception as _gm_e:
            _logger.warning("performance mode (voice) failed: %s", _gm_e)
            speak("Performance mode hit a snag, sir.")
        _log_turn("user", question)
        return True

    # ── v1.30: Lightroom import (voice-only now — global hotkey was unbound) ──
    if getattr(cfg, "WORK_MODE_AVAILABLE", True) and _LR_IMPORT_RE.search(question):
        _console_turn("You", question)
        try:
            import wivexx_workmode
            resp = wivexx_workmode.import_latest_to_lightroom(cfg, logger=_logger)
        except Exception as _lr_e:
            _logger.warning("lightroom import (voice) failed: %s", _lr_e)
            resp = "I couldn't launch Lightroom Classic, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.30: transparent in-game OSD (click-through, see-through) ───────────
    _m_osd = _OSD_RE.search(question)
    if _m_osd:
        _console_turn("You", question)
        verb = (_m_osd.group(1) or "").lower().strip()
        want = True if verb in ("show", "enable", "turn on", "open") else \
            False if verb in ("hide", "disable", "turn off", "close") else None
        if _wivexx_gui is not None and hasattr(_wivexx_gui, "toggle_perf_osd"):
            try:
                _wivexx_gui.toggle_perf_osd(want)
                resp = ("Gaming overlay hidden, sir." if want is False
                        else "Gaming overlay is up, sir — transparent and click-through.")
            except Exception as _osd_e:
                _logger.warning("perf OSD toggle failed: %s", _osd_e)
                resp = "I couldn't toggle the gaming overlay, sir."
        else:
            resp = "The gaming overlay only runs on the PC, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.30: internet speed test (our own probe) ────────────────────────────
    if _SPEEDTEST_RE.search(question):
        _console_turn("You", question)
        speak("Running a speed test, sir — one moment.")
        try:
            import wivexx_sysmon
            r = wivexx_sysmon.speedtest()
            if r and r.get("down_mbps"):
                up = (" and %.0f up" % r["up_mbps"]) if r.get("up_mbps") else ""
                resp = "About %.0f megabits down%s, sir." % (r["down_mbps"], up)
            else:
                resp = "I couldn't reach the speed-test server, sir."
        except Exception as _st_e:
            _logger.warning("speedtest (voice) failed: %s", _st_e)
            resp = "The speed test failed, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.31: transcribe an audio/video file to text (+ subtitles) ───────────
    if _TRANSCRIBE_RE.search(question):
        _console_turn("You", question)
        _gui_mod = None
        path = ""
        try:
            import wivexx_gui as _gui_mod
            speak("Sure, sir — pick the audio or video file.")
            path = _gui_mod.ask_open_media()
        except Exception as _pe:
            _logger.warning("media picker failed: %s", _pe)
        return _do_transcribe(path, _gui_mod, question)

    # ── v1.30: on-demand optimizer (free RAM + report CPU/GPU hogs) ───────────

    # ── v1.30: on-demand optimizer (free RAM + report CPU/GPU hogs) ───────────
    _m_opt = _OPTIMIZE_RE.search(question)
    if _m_opt:
        _console_turn("You", question)
        field = _OPT_FIELD.get((_m_opt.group(1) or "").lower(), "all")
        try:
            import wivexx_optimize as _opt
            speak("On it, sir — optimizing now.")    # ack (the work takes ~1–2 s)
            res = _opt.optimize(field)
            resp = _opt.summary_line(res)
        except Exception as _opt_e:
            _logger.warning("optimize failed: %s", _opt_e)
            resp = "I couldn't optimize just now, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.30: performance HUD (live hardware stats) ──────────────────────────
    _m_perf = _PERF_HUD_RE.search(question)
    if _m_perf:
        _console_turn("You", question)
        verb = (_m_perf.group(1) or "").lower()
        show = True if verb in ("show", "open", "bring up") else \
            False if verb in ("hide", "close") else None
        # v1.34: ONE overlay everywhere — the HUD voice command now drives the
        # same transparent click-through OSD as optimized mode + Ctrl+Shift+O
        if _wivexx_gui is not None and hasattr(_wivexx_gui, "toggle_perf_osd"):
            try:
                _wivexx_gui.toggle_perf_osd(show)
                resp = ("Performance overlay hidden, sir." if show is False
                        else "Performance overlay is up, sir.")
            except Exception as _ph_e:
                _logger.warning("perf overlay toggle failed: %s", _ph_e)
                resp = "I couldn't toggle the performance overlay, sir."
        else:
            resp = "The performance overlay only runs on the PC, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.16: status overlay (state + timers + token cost) ───────────────────
    _m_ov = _OVERLAY_RE.search(question)
    if _m_ov:
        _console_turn("You", question)
        verb = (_m_ov.group(1) or "").lower()
        show = True if verb in ("show", "open", "bring up") else \
            False if verb in ("hide", "close") else None
        if _wivexx_gui is not None and hasattr(_wivexx_gui,
                                               "toggle_status_overlay"):
            try:
                _wivexx_gui.toggle_status_overlay(show)
                resp = ("Status overlay hidden, sir." if show is False
                        else "Status overlay is up, sir.")
            except Exception as _ov_e:
                _logger.warning("status overlay toggle failed: %s", _ov_e)
                resp = "I couldn't toggle the overlay, sir."
        else:
            resp = "The status overlay only runs on the PC, sir."
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.17: voice-ID enrollment ────────────────────────────────────────────
    # ── v1.58: guided wake-word tuning (personalized wake profile) ───────────
    # (review fix: questions ABOUT the wake word — "how do I train the wake
    # word?" — must not launch the guided capture; only imperatives do.)
    if _WAKE_TUNE_RE.search(question) and not _INTERROGATIVE_RE.search(
            _SPEECH_FILLER_RE.sub(' ', question).strip()):
        _console_turn("You", question)
        try:
            import wivexx_i18n as _i18n_wt
            _wt_lang = "es" if _i18n_wt.turn_language() == "es" else "en"
        except Exception:
            _wt_lang = "en"
        resp = _enroll_wake_profile(_wt_lang)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    if _ENROLL_RE.search(question):
        _console_turn("You", question)
        set_tray("listening", "Wivexx — enrolling voice")
        resp = _handle_voice_enroll()
        set_tray("standby", "Wivexx — standby")
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── v1.17: screen-click overlay — "click N" before "number the screen" ────
    _m_click = _CLICK_N_RE.search(question)
    _click_num = _parse_spoken_int(_m_click.group(1)) if _m_click else None
    if _click_num is not None and _screen_click_map:
        _console_turn("You", question)
        resp = _handle_screen_click(_click_num)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True
    if _SCREEN_LABEL_RE.search(question):
        _console_turn("You", question)
        _gui_busy("screenui", "Reading the screen…")
        resp = _handle_screen_label()
        _gui_busy("screenui", None)
        _log_turn("user", question)
        _log_turn("assistant", resp)
        speak(resp)
        return True

    # ── Unknown system command — in-character fallback ────────────────────────
    # Strip fillers before interrogative check so "OK, so how do I start X"
    # correctly matches _INTERROGATIVE_RE after stripping "OK, so".
    _q_for_interrog = _SPEECH_FILLER_RE.sub(' ', question).strip()
    _is_screen_req  = _wants_screen(question) or _wants_multi_monitor(question)
    _FALLBACK_VERBS = [
        "open", "close", "launch", "run", "turn", "set", "show", "hide",
        "start", "stop", "play", "pause", "mute", "lock", "screenshot",
        "quit", "exit", "kill", "shutdown", "enable", "disable",
        "install", "uninstall",
    ]
    if (
        _SYSTEM_ACTION_RE.search(question)
        and not _INTERROGATIVE_RE.search(_q_for_interrog)
        and not _is_screen_req
        and _is_imperative_command(question, _FALLBACK_VERBS)
    ):
        _console_turn("You", question)
        fallback = _handle_unknown_command(question)
        _log_turn("user", question)
        _log_turn("assistant", fallback)
        speak(fallback)
        # v1.6: no more automatic "log it as a request?" follow-up — requests
        # are only logged when explicitly asked for ("create a request", etc.)
        # or when added through the GUI command editor.
        return True

    # ── Feature 4: token usage query ─────────────────────────────────────────
    if _USAGE_QUERY_RE.search(question):
        _console_turn("You", question)
        speak(_speak_usage())
        return True

    return _converse(_orig_question, q_en=question)   # conversation gets the user's own words


def _converse(question: str, q_en: str = "") -> bool:
    """The conversational tail of a turn — ship the utterance to Claude (with
    optional screen capture / web search) and speak the answer. Reached either
    by falling through the whole command waterfall, or directly when the
    intent gate is confident the utterance is conversation (v1.33).
    *q_en* is the English canonicalization of a non-English turn (v1.34) so
    the search-need triggers work in every language."""
    _console_turn("You", question)

    capture = _wants_screen(question) or _wants_multi_monitor(question)
    search  = _needs_search(question) or (bool(q_en) and _needs_search(q_en))

    if capture:
        set_tray("processing", "Wivexx — scanning screens")
    elif search:
        print("[Search] Querying the web...")
        set_tray("searching", "Wivexx — searching")
    else:
        set_tray("processing", "Wivexx — processing")

    # v1.58: capture anything the user says DURING thinking (the API call blocks
    # the loop with no mic monitoring — the "deadzone"). A follow-up gets queued
    # and answered next instead of being lost. Only for open-mic voice sessions;
    # never for typed/remote/call turns. The thread is ALWAYS stopped + joined
    # (inner finally) before any speak() runs, so the mic is never double-owned.
    _think_stop = threading.Event()
    _think_cap = None
    # v1.58 review fix: also require _VOICE_TURN — a TYPED turn processed while
    # a voice session idles would otherwise double-capture (the session loop's
    # own recorder hears the same words) and answer the utterance twice.
    if (_in_active_session and _VOICE_TURN and not _call_mode
            and _remote_capture is None):
        try:
            _think_cap = threading.Thread(
                target=_listen_during_thinking, args=(_think_stop,),
                daemon=True, name="wivexx-thinking-capture")
            _think_cap.start()
        except Exception:
            _think_cap = None
    try:
        try:
            answer = _ask_claude(question, include_screen=capture, include_search=search)
            # v1.34 SELF-HEALING SEARCH: if the triggers missed but the model says
            # it needs (or promises…) a live search — the [SEARCH] protocol token or
            # a narrated promise — actually RUN the search and answer for real.
            # Previously the promise was a dead end: "performing the search right
            # now…" and then nothing, ever.
            if not search and _wants_search_retry(answer):
                print("[Search] Model requested a live search — retrying with the tool.")
                set_tray("searching", "Wivexx — searching")
                # drop the marker/promise exchange from history so the retry is clean
                try:
                    if len(_conversation) >= 2:
                        _conversation.pop(); _conversation.pop()
                except Exception:
                    pass
                answer = _ask_claude(question, include_screen=capture, include_search=True)
        finally:
            # release the mic BEFORE any speak() (the except paths speak too)
            _think_stop.set()
            if _think_cap is not None:
                _think_cap.join(timeout=1.5)
    except anthropic.APIConnectionError:
        speak("No connection, sir.")
        return True
    except anthropic.AuthenticationError:
        speak("Authentication error, sir. Please verify the API key.")
        return True
    except anthropic.RateLimitError:
        speak("Rate limit reached, sir.")
        time.sleep(5)
        return True
    except Exception as e:
        print(f"[API Error] {e}")
        speak("I encountered an error, sir.")
        return True

    if "[SEARCH]" in (answer or ""):       # never speak the protocol token
        answer = answer.replace("[SEARCH]", "").strip() or \
                 "I couldn't retrieve live results right now, sir."
    answer = _sanitize_response(answer)
    set_tray("speaking", "Wivexx — speaking")
    speak(answer)
    return True


def _active_session() -> None:
    global _wake_cooldown_until, _in_active_session
    _in_active_session = True
    # v1.58 review fix: a thinking-window follow-up queued in a PREVIOUS session
    # that ended before consuming it is stale by definition — never answer an
    # hours-old clip as this session's first turn.
    _pending_followup_audio.clear()
    try:
        speak("Yes, sir?")
        set_tray("active", "Wivexx — active session")
        print("[SESSION] Active -- say 'stop' or 'stand down' to return to standby.")
        print("-" * 56)

        while True:
            if _mic_muted:
                set_tray("mic_muted", "Wivexx — MIC MUTED  [Ctrl+Shift+M to unmute]")
                time.sleep(0.15)
                continue

            if _call_mode:
                # Call mode entered mid-session: idle here — turns arrive via PTT.
                time.sleep(0.15)
                continue

            if _wake_enrolling:
                # v1.58 review fix: guided wake tuning owns the mic — without
                # this, the session recorder captures the enrollment samples
                # too and answers them as turns after the flow ends.
                time.sleep(0.15)
                continue

            set_tray("listening", "Wivexx — listening")

            audio = _pop_interrupt_audio()
            if audio is None:
                # v1.11: VAD-triggered capture — recording only starts when a
                # HUMAN VOICE is detected, not on any loud noise; and an idle
                # session auto-returns to standby instead of transcribing the
                # room forever (the "random words" bug).
                idle_secs = float(getattr(cfg, "SESSION_IDLE_TIMEOUT", 30))
                audio = _record_triggered(max_wait=idle_secs)
                if audio is None:
                    _wake_cooldown_until = time.time() + 1.0
                    set_tray("standby", "Wivexx — standby")
                    print("[SESSION] Idle — returning to standby.")
                    print("-" * 56)
                    return

            question = _transcribe(audio, strict=True).strip()
            if not question:
                continue

            global _VOICE_TURN
            _VOICE_TURN = True                       # this turn can confirm by voice
            try:
                still_active = _handle_turn(question)
            finally:
                _VOICE_TURN = False
            _drain_timer_queue()   # speak any timer notifications that fired
            if not still_active:
                _wake_cooldown_until = time.time() + 1.5
                set_tray("standby", "Wivexx — standby")
                print("[SESSION] Ended. Returning to standby.")
                print("-" * 56)
                return
    finally:
        _in_active_session = False


# ══════════════════════════════════════════════════════════════════════════════
#  WAKE WORD LISTENER
# ══════════════════════════════════════════════════════════════════════════════
# v1.34: fuzzy phonetic wake net. "Wivexx" is a coined word — other people's
# accents produce mishears OUTSIDE the exact-variant list ("wyvex", "wivets",
# "vybex", "we barks"…) and the exact-substring check silently ignored them,
# which is why friends couldn't wake it. A token (or merged word-pair) close
# enough to a reference core now counts as the wake word.
# v1.57: the fuzzy net is a phonetic SAFETY NET for the coined word's mishears — it
# must NOT collide with ordinary words. Two changes vs the old net (which false-woke
# on Spanish "vives"/"vívelo"):
#  • refs dropped to the coined-word cores only. "vivex" is GONE (it scored 0.80 vs
#    the very common Spanish "vives"); "jarvis" is GONE (distinctive — it has its own
#    literal variants and never needs fuzzing). "vivex" is still a LITERAL variant, so
#    a genuine "vivex" mishear still matches exactly (Spanish "vives" ≠ substring).
#  • threshold 0.72 → 0.80, and NO adjacent-token gluing (the "we"+"vex" merge doubled
#    the false-accept surface for almost no recall gain).
_WAKE_FUZZY_REFS = ("wivex", "wybex", "wibex")
_WAKE_FUZZY_THRESHOLD = 0.80

# v1.58: shared accent-aware tokenizer for ALL wake matching. Whisper renders the
# user's Spanish attempts with accented letters ('güi bex'); the old [a-z]+
# tokenizer SHATTERED those tokens ('güi' → 'g','i') so accented profile
# renderings could never match — the ES profile was silently inert. Tokens are
# de-accented here and wivexx_wake_profile._norm de-accents on store, so both
# sides compare in plain ASCII.
_WAKE_DEACCENT = str.maketrans("áéíóúüñ", "aeiouun")


def _wake_tokens(t: str) -> list:
    return [w.translate(_WAKE_DEACCENT)
            for w in re.findall(r"[a-záéíóúüñ]+", t)]


def _fuzzy_wake_hit(t: str) -> Optional[str]:
    toks = _wake_tokens(t)
    prof = _wake_profile_refs()
    for w in toks:                                # single tokens only (no gluing)
        wn = re.sub(r"(.)\1+", r"\1", w)          # collapse doubled letters
        if not 4 <= len(wn) <= 9:
            continue
        for ref in _WAKE_FUZZY_REFS:
            if difflib.SequenceMatcher(None, wn, ref).ratio() >= _WAKE_FUZZY_THRESHOLD:
                return w
        # v1.58: the owner's enrolled renderings count here too (lenient profile
        # threshold) — 'Ybix, play some music' is a wake+command in one breath
        # that the ≤2-word opener path can't cover; the boundary rule in
        # _is_wake_word still position-gates whatever matches here.
        for ref in prof:
            if difflib.SequenceMatcher(None, wn, ref).ratio() >= _WAKE_PROFILE_THRESHOLD:
                return w
    return None


# v1.57 Tier 3: the tiny wake model transcribes the COINED "hey wivexx" as ordinary-
# sounding openers that vary by accent — "hey why vex" (US), "hey we vex"/"hey wevex"
# (Castilian), "hey webex"/"hey we backs" (Mexican). Those BARE forms are too common to
# accept mid-sentence, but a GREETING LEAD-IN immediately followed by a wivexx-SOUNDING
# token is strong, safe evidence of addressing. Tuned to 0 false-accepts across a broad
# "hey <ordinary word>" battery (threshold 0.82); the boundary rule below already blocks
# any non-opener match. This recovers brand-phrase recall the T1 variant-pruning lost.
# v1.58: "oye" added — the natural Spanish greeting ('oye, Wivexx') gets the
# same widened boundary head + opener treatment as "hey" (the profile module's
# ref-building lead-in list already knew it; the live matcher didn't).
_WAKE_LEADINS = ("hey", "ok", "okay", "hi", "yo", "oye")
_WAKE_OPENER_REFS = ("wivex", "wybex", "wibex", "vivex", "webex", "wevex", "whyvex", "webacks")
_WAKE_OPENER_THRESHOLD = 0.82
# v1.58: a BARE spoken "Wivexx" (no greeting) is the most common way people wake
# it, and the tiny model splits the coined word into two ordinary tokens ("we
# vex", "why vex", "we backs", "with vex"). Those were pruned from the literal
# variants (they collide with mid-sentence talk), so bare "Wivexx" only woke ~50%
# of the time — the "I have to say it 3 times" bug. The fix: accept a wivexx-
# sounding OPENER on a SHORT utterance even without a greeting. It's safe because
# real false-accepts ("abrí el webex del trabajo", "we vexed the whole team
# yesterday") live inside LONGER sentences — this only looks at short ones.
_WAKE_BARE_MAX_WORDS = 2


# v1.58: the owner's ENROLLED renderings are the user's OWN voice, so a match to
# them is stronger evidence than a match to a generic spelling — they're checked
# at a MORE LENIENT threshold (0.72 vs 0.82). Tuned on the user's real renderings:
# lifts recall on their own voice from ~41% to ~71% with 0 false-accepts on
# ordinary short speech (the ≤2-word bare gate blocks the sentence-length ones).
_WAKE_PROFILE_THRESHOLD = 0.72


def _wivexx_sounding(tok: str) -> bool:
    """True when a token phonetically matches the coined wake word: generic
    reference spellings at the strict threshold, OR the owner's own enrolled
    renderings ('whymix', 'whybix' …) at the lenient profile threshold."""
    cc = re.sub(r"(.)\1+", r"\1", tok)
    if not 4 <= len(cc) <= 10:
        return False
    if any(difflib.SequenceMatcher(None, cc, ref).ratio() >= _WAKE_OPENER_THRESHOLD
           for ref in _WAKE_OPENER_REFS):
        return True
    prof = _wake_profile_refs()
    return bool(prof) and any(
        difflib.SequenceMatcher(None, cc, ref).ratio() >= _WAKE_PROFILE_THRESHOLD
        for ref in prof)


def _hey_wivexx_opener(t: str) -> bool:
    """True when the utterance is a wivexx-ADDRESSING opener the literal variants
    miss, in either of two safe forms:
      (a) a greeting lead-in ('hey'/'ok'/'hi'/'yo') + a wivexx-sounding token (or
          the next two glued, e.g. 'why'+'vex') — 'hey wivexx' rendered by the
          tiny model as 'hey why vex' / 'hey webex' …;
      (b) a SHORT utterance (≤3 words, NO greeting) whose opening token — or its
          first two tokens glued — sounds like the coined word: a bare 'Wivexx'
          heard as 'we vex' / 'why vex' / 'we backs'. The short-utterance gate is
          what keeps this from firing on the mid-sentence lookalikes in real talk."""
    words = _wake_tokens(t)
    if not words:
        return False
    # (a) greeting lead-in + wivexx-sounding token (or next two glued)
    if len(words) >= 2 and words[0] in _WAKE_LEADINS:
        cands = [words[1]]
        if len(words) >= 3:
            cands.append(words[1] + words[2])
        if any(_wivexx_sounding(c) for c in cands):
            return True
    # (b) bare short utterance (≤2 words) — the whole opening token, or the first
    # two glued. A single mishear token ('whyvex') and a two-token split ('we vex')
    # both land here; a THIRD word means it's a sentence, not a bare wake, so it's
    # excluded (keeps 'web ex' waking but 'web ex meeting' quiet). A leading 1-2
    # letter JUNK token from a noisy transcription ('A Y-Bix', 'FX, Wii Bix')
    # doesn't count against the limit — the utterance is also tried without it.
    alt = (words[1:] if len(words) >= 2 and len(words[0]) <= 2
           and words[0] not in _WAKE_LEADINS else None)
    for cand_words in (words, alt):
        if not cand_words or len(cand_words) > _WAKE_BARE_MAX_WORDS:
            continue
        cands = [cand_words[0]]
        if len(cand_words) >= 2:
            cands.append(cand_words[0] + cand_words[1])
        if any(_wivexx_sounding(c) for c in cands):
            return True
    return False


def _is_wake_word(transcript: str) -> bool:
    t = transcript.lower().strip(" .,!?")
    words = t.split()

    matched_variant = None
    # v1.58: the owner's enrolled renderings (wake profile) are first-class
    # variants — recognition tuned to how THIS user says the word.
    for variant in (_WAKE_VARIANTS | _load_custom_wake() | _wake_profile_words()):
        if variant in t:
            matched_variant = variant
            break
    if matched_variant is None and cfg.WAKE_WORD.lower() in t:
        matched_variant = cfg.WAKE_WORD.lower()
    if matched_variant is None:                    # v1.34: fuzzy phonetic net
        _fz = _fuzzy_wake_hit(t)
        if _fz:
            _logger.debug("wake: fuzzy hit %r in %r", _fz, t[:60])
            matched_variant = _fz

    if matched_variant is None:
        # v1.57 Tier 3: last resort — a greeting lead-in + a wivexx-sounding token
        # ('hey why vex' / 'hey webex' …), how the tiny model renders 'hey wivexx'.
        return _hey_wivexx_opener(t)

    # v1.57: the wake word must ADDRESS Wivexx — it appears at the START of the
    # utterance (in the first two words), not buried mid-sentence. This kills the
    # false wakes where a fuzzy/literal lookalike showed up later in ordinary talk.
    # (The old rule accepted a match ANYWHERE in any ≤6-word utterance.)
    # v1.58: a greeting lead-in shifts the wake word to words 2-3, so a TWO-WORD
    # variant ("why bex") can never fit inside a 2-word head — check the first
    # THREE words when the utterance opens with a greeting ("hey, why bex").
    # (Review fix: the widened head is for MULTI-WORD variants only. A single-
    # token match sitting at word 3 behind a greeting means an ORDINARY word
    # intervenes — "ok open webex" — which is a command about that word, not an
    # address; single tokens must appear within the first two words.)
    head_n = (3 if words and words[0].strip(" .,!?") in _WAKE_LEADINS
              and " " in matched_variant else 2)
    head = " ".join(words[:head_n])
    if (t.startswith(matched_variant)
            or t.startswith(cfg.WAKE_WORD.lower())
            or matched_variant in head):
        return True

    # v1.58: a variant that matched but FAILED the boundary check used to return
    # False outright — short-circuiting the opener fallback that would have
    # caught it ("hey, why bex" → variant "why bex" matched mid-head → rejected
    # → opener never ran → miss). Always give the opener the last word.
    return _hey_wivexx_opener(t)


# ── Porcupine wake-word engine (optional; falls back to Whisper) ──────────────
_porcupine = None
_porcupine_tried = False
# v1.57 Tier 3: the optional openWakeWord acoustic detector module (set at boot in
# _init_wivexx; stays None if openWakeWord isn't installed → Whisper wake path).
_wakeword_mod = None
# v1.58: True while the guided wake-word enrollment is recording — the wake loop
# must ignore the mic then (the user is literally saying the wake word 6 times).
# The lock makes the check-and-set atomic across the three trigger threads.
_wake_enrolling = False
_wake_enroll_lock = threading.Lock()
# v1.58 adaptive learning: recent near-miss renderings (ts, text) + the rendering
# that WON the current wake. When a wake succeeds, the near-misses from the last
# few seconds were almost certainly failed attempts at the SAME wake — they get
# added to the profile so next time they succeed. This is how the system converges
# to the user's full spread of renderings from ordinary use.
_wake_recent_misses: list = []
_last_wake_render = ""
_WAKE_MISS_WINDOW_SECS = 12.0


def _plausible_wake_render(text: str) -> bool:
    """Shared junk gate for anything written into the wake profile (guided
    enrollment AND adaptive learning): non-empty, short (≤4 words), not a known
    Whisper hallucination, not just greeting lead-ins."""
    t = (text or "").lower().strip(" .,!?¿¡")
    words = _wake_tokens(t)
    return bool(words) and len(words) <= 4 \
        and t not in _WHISPER_HALLUCINATIONS \
        and not all(w in _WAKE_LEADINS for w in words)


# v1.58 review fix: adaptive learning needs a PHONETIC gate on top of the junk
# gate — a near-miss is any short burst, including ambient speech to other
# people ('hold on', 'un momento') heard just before a genuine wake. Learning
# those verbatim would hand them a 0.72-threshold ref and create a
# self-reinforcing false-accept loop. Only text that already SOUNDS somewhat
# like the wake word (≥0.55 vs the generic + enrolled refs — well below the
# match thresholds, so genuinely new renderings like 'why mix' @0.67 pass)
# may be learned. Guided enrollment skips this gate: there the user was ASKED
# to say the word, so an unrecognizable rendering is exactly the data we want.
_WAKE_LEARN_THRESHOLD = 0.55


def _sounds_near_wake(text: str) -> bool:
    words = _wake_tokens((text or "").lower())
    while len(words) > 1 and words[0] in _WAKE_LEADINS:
        words = words[1:]
    glued = re.sub(r"(.)\1+", r"\1", "".join(words))
    if not 3 <= len(glued) <= 12:
        return False
    refs = _WAKE_OPENER_REFS + _WAKE_FUZZY_REFS + _wake_profile_refs()
    return any(difflib.SequenceMatcher(None, glued, ref).ratio()
               >= _WAKE_LEARN_THRESHOLD for ref in refs)


def _wake_learn(render: str) -> None:
    """Teach the wake profile the winning rendering + any near-misses that came
    just before it (failed attempts at the same wake). Fail-safe, deduped,
    capped — and gated (junk + phonetic) so ambient speech is never learned."""
    try:
        import wivexx_wake_profile as _wp
        import wivexx_i18n as _i18n
        lang = "es" if _i18n.current_language() == "es" else "en"
        now = time.time()
        learn = []
        r = (render or "").strip()
        if r:
            learn.append(r)
        for ts, txt in _wake_recent_misses:
            if now - ts <= _WAKE_MISS_WINDOW_SECS and txt:
                learn.append(txt)
        _wake_recent_misses.clear()
        for txt in learn:
            if _plausible_wake_render(txt) and _sounds_near_wake(txt):
                _wp.add_sample(lang, txt)      # deduped + capped inside the module
            else:
                _logger.debug("wake learn: rejected %r", txt[:40])
    except Exception as _le:
        _logger.debug("wake learn skipped: %s", _le)


def _init_porcupine() -> None:
    """Porcupine (Picovoice) is a dedicated wake-word engine that needs an API
    ACCESS KEY. We don't have one, so it's DISABLED (user request, 2026-06-23) —
    Wivexx uses its local Whisper-based wake detection, which needs NO API key.
    To re-enable later: restore the key/keyword-file logic from git history."""
    global _porcupine
    _porcupine = None
    _logger.info("Porcupine disabled (no API key) — using local Whisper wake word.")


def _wake_loop_porcupine() -> bool:
    """Always-on Porcupine wake detection. Returns True when the keyword fires;
    False if the dedicated 16 kHz stream / processing fails (→ Whisper fallback)."""
    fl   = _porcupine.frame_length
    rate = _porcupine.sample_rate            # Porcupine requires 16 000 Hz
    try:
        stream = sd.InputStream(samplerate=rate, channels=1, dtype="int16",
                                blocksize=fl, device=_get_device())
    except Exception as e:
        _logger.warning("Porcupine: 16 kHz mic stream failed (%s) — Whisper wake.", e)
        return False
    with stream:
        while True:
            frame, _ = stream.read(fl)
            # call mode / PTT / our own TTS / cooldown / enrolling: ignore mic
            if (_mic_muted or _call_mode or _ptt_recording or _tts_active
                    or _wake_enrolling or time.time() < _wake_cooldown_until):
                continue
            pcm = frame[:, 0]
            if _wivexx_gui:
                try:
                    rms = float(np.sqrt(np.mean(pcm.astype(np.float32) ** 2)))
                    _wivexx_gui.push_level(rms / 32768)
                except Exception:
                    pass
            try:
                if _porcupine.process(pcm.tolist()) >= 0:
                    _logger.info("Wake word detected (Porcupine) — starting session")
                    return True
            except Exception as e:
                _logger.warning("Porcupine process error (%s) — Whisper wake.", e)
                return False


def _wake_require_voice() -> bool:
    """Settings → 'Only wake for my voice' (v1.57 Tier 2, default OFF). Only has an
    effect when a voiceprint is enrolled AND resemblyzer is installed — otherwise the
    speaker gate fails open (nothing is ever locked out)."""
    try:
        import wivexx_settings as _ws
        return bool(_ws.get("wake_require_voice", False))
    except Exception:
        return False


def _wake_confirm(audio: np.ndarray) -> bool:
    """Second-stage wake verification (v1.57 Tier 2) — the Alexa/Siri two-stage
    pattern. Runs ONLY after the fast tiny-model stage-1 already fired, so the cost
    is paid on rare wake CANDIDATES, not the always-on loop. REJECT-ONLY + fail-open:
    it can veto a false stage-1 hit, but any internal error/uncertainty ACCEPTS, so
    wake is never LESS reliable than stage-1 alone.
      (1) Accuracy re-check — re-decode the SAME clip with the accurate main model
          under its strict confidence gate; if that model CONFIDENTLY hears something
          that is NOT the wake word (the classic Spanish residual, e.g. 'vives muy
          lejos'), veto. An empty/low-confidence result is treated as unsure → accept.
      (2) Optional speaker gate — if the owner enrolled a voiceprint AND turned on
          Settings → 'Only wake for my voice', require the clip to match their voice
          (rejects the TV / other people / a different speaker)."""
    # (1) accuracy re-verification with the accurate (non-tiny) model.
    # v1.58: only VETO when the accurate model hears a clearly LONG utterance that
    # isn't the wake word — i.e. the casual-sentence false-accept case ('vives muy
    # lejos de aquí'). A genuine wake attempt is SHORT ('wivexx', 'hey wivexx'), and
    # the accurate model may render that coined word as its own non-wake tokens
    # ('we vex') — vetoing those was a big part of the "say it 3 times" misses. So a
    # short strict transcript is trusted (the stage-1 hit stands); only a long one
    # can overturn it.
    try:
        strict_txt = _transcribe(audio, "", strict=True)
        # v1.58 review fix: gate at ≥3 words (was ≥4). 1-2 word strict
        # transcripts are trusted — the accurate model may render the coined
        # word as its own short tokens ('we vex'), and _is_wake_word's bare +
        # profile paths already recognize those. But a confident 3+ word
        # NON-wake ('vives muy lejos') is exactly the casual-sentence class
        # this stage was built to reject — ≥4 had disarmed it.
        if strict_txt and len(strict_txt.split()) >= 3 and not _is_wake_word(strict_txt):
            _logger.debug("wake: stage-2 vetoed — accurate model heard %r", strict_txt[:60])
            return False
    except Exception as _e:
        _logger.debug("wake: stage-2 accuracy check skipped (%s)", _e)
    # (2) optional speaker gate (owner-only wake)
    try:
        if _wake_require_voice():
            import wivexx_voiceid as _vid
            if _vid.available() and _vid.enrolled():
                ok, score = _vid.verify(audio, SAMPLE_RATE)
                if not ok:
                    _logger.info("wake: rejected — voice didn't match the owner (%.2f)", score)
                    return False
    except Exception as _e:
        _logger.debug("wake: speaker gate skipped (%s)", _e)
    return True


def _wake_acoustic_enabled() -> bool:
    """Settings → 'Best-in-class wake detection' (v1.57 Tier 3, default ON). When on
    AND openWakeWord is installed, a dedicated acoustic model scores the SOUND of the
    wake phrase (near-zero false accepts) as the primary stage-1 detector. The Whisper
    path still runs alongside to cover the coined brand phrase. Off → Whisper only."""
    try:
        import wivexx_settings as _ws
        return bool(_ws.get("wake_acoustic", True))
    except Exception:
        return True


def _wake_stage1(audio: np.ndarray) -> bool:
    """Stage-1 wake detection on a captured clip (v1.57 Tier 3). Fires if EITHER the
    best-in-class ACOUSTIC detector (openWakeWord, when enabled+ready) OR the improved
    Whisper path recognises the wake word. openWakeWord gives near-zero false accepts
    for its models (incl. 'hey_jarvis' + any custom hey_wivexx.onnx); the Whisper path
    keeps the coined 'hey wivexx' working until a custom acoustic model is trained, and
    is the fallback when openWakeWord isn't present. A hit here is only a CANDIDATE —
    it is still gated by _wake_confirm (stage 2: accurate re-decode + optional speaker
    verification), so adding the acoustic path never lowers precision."""
    global _last_wake_render
    _last_wake_render = ""
    # (1) acoustic detector — high precision, essentially never false-fires
    if _wake_acoustic_enabled():
        try:
            if _wakeword_mod is not None and _wakeword_mod.ready():
                name, score = _wakeword_mod.scan_clip(audio, SAMPLE_RATE)
                if name:
                    _logger.info("Wake candidate — acoustic %s (%.2f)", name, score)
                    return True
        except Exception as _e:
            _logger.debug("acoustic wake scan failed (%s)", _e)
    # (2) Whisper path — covers the coined brand phrase + is the universal fallback
    try:
        _wtxt = _transcribe_wake(audio)
        if _is_wake_word(_wtxt):
            _last_wake_render = (_wtxt or "").strip()   # for adaptive learning
            return True
        # v1.58: surface likely NEAR-MISSES (a short, non-empty burst that didn't
        # match) at INFO so real-world wake renderings are diagnosable + tunable
        # without flooding the log with ambient speech. Long bursts are ordinary
        # talk, not wake attempts — stay quiet for those. Also ring-buffer them:
        # a near-miss just before a real wake was a failed attempt to learn from.
        _wt = (_wtxt or "").strip()
        if _wt and 1 <= len(_wt.split()) <= 4:
            _logger.info("wake near-miss — heard %r (no match)", _wt[:60])
            _wake_recent_misses.append((time.time(), _wt))
            if len(_wake_recent_misses) > 6:
                _wake_recent_misses.pop(0)
    except Exception as _e:
        _logger.debug("whisper wake path failed (%s)", _e)
    return False


def _listen_for_wake_word() -> None:
    global _wake_cooldown_until, _porcupine, _porcupine_tried
    if not _porcupine_tried:
        _init_porcupine()
        _porcupine_tried = True
    if _porcupine is not None:
        if _wake_loop_porcupine():
            return
        _porcupine = None        # stream/process failed — fall back to Whisper

    # ── Whisper-based wake detection (fallback / default) ──────────────────────
    chunk_n  = int(SAMPLE_RATE * 0.05)
    vad_mult = getattr(cfg, "VAD_THRESHOLD", 0.02)
    gate     = max(cfg.SILENCE_THRESHOLD * vad_mult / 0.01, 80)
    device   = _get_device()
    v_prompt = _load_calibration().get("voice_sample", "")
    pre_buf: list[np.ndarray] = []
    pre_size = 12
    consec   = 0

    with sd.InputStream(
        samplerate=SAMPLE_RATE, channels=1,
        dtype="int16", blocksize=chunk_n, device=device,
    ) as stream:
        while True:
            block, _ = stream.read(chunk_n)

            # Call mode / PTT capture / own TTS / wake enrollment: ignore
            # open-mic audio entirely
            if (_mic_muted or _call_mode or _ptt_recording or _tts_active
                    or _wake_enrolling or time.time() < _wake_cooldown_until):
                pre_buf.clear()
                consec = 0
                continue

            pre_buf.append(block.copy())
            if len(pre_buf) > pre_size:
                pre_buf.pop(0)

            rms = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
            if _wivexx_gui:
                try:
                    _wivexx_gui.push_level(rms / 32768)
                except Exception:
                    pass
            if rms > gate:
                consec += 1
                if consec >= 2:            # v1.38: fire on 100ms of speech (was 150)
                    chunks = list(pre_buf)
                    silent = 0
                    # v1.38: the wake phrase is short ("hey wivexx" ~1s), so cap
                    # the capture at ~2.2s and stop after ~0.45s of silence — this
                    # alone shaves ~1.5s off the old 4s / 0.8s window.
                    max_silent = int(0.45 / 0.05)
                    while len(chunks) < int(2.2 / 0.05):
                        block, _ = stream.read(chunk_n)
                        chunks.append(block.copy())
                        r = float(np.sqrt(np.mean(block.astype(np.float32) ** 2)))
                        if r < cfg.SILENCE_THRESHOLD:
                            silent += 1
                            if silent >= max_silent:
                                break
                        else:
                            silent = 0
                    audio = np.concatenate(chunks).flatten()
                    # Stage 1 (v1.57 Tier 3): acoustic openWakeWord OR the improved
                    # Whisper path. Stage 2 (Tier 2): reject-only _wake_confirm re-checks
                    # the SAME clip with the accurate model + optional speaker gate.
                    if _wake_stage1(audio) and _wake_confirm(audio):
                        _logger.info("Wake word detected — starting session")
                        # v1.58: learn this rendering (+ the near-misses that just
                        # preceded it) so the profile converges to the user's voice.
                        try:
                            _wake_learn(_last_wake_render)
                        except Exception:
                            pass
                        return
                    consec = 0
                    pre_buf = []
            else:
                consec = 0


# ══════════════════════════════════════════════════════════════════════════════
#  MAIN
# ══════════════════════════════════════════════════════════════════════════════
def _run_hotkey_command(command: str) -> None:
    """Execute a custom-shortcut voice command as if it had been spoken."""
    _logger.info("Hotkey command: '%s'", command)
    try:
        _handle_turn(command)
    except Exception as e:
        _logger.warning("Hotkey command failed: %s", e)


def _hotkey_toggle_goals_overlay() -> None:
    """Ctrl+Shift+G (v1.11): show/hide the goals overlay from anywhere."""
    try:
        if _wivexx_gui:
            _wivexx_gui.toggle_goals_overlay()
    except Exception as e:
        _logger.warning("goals overlay toggle failed: %s", e)


def _hotkey_toggle_timer_overlay() -> None:
    """Ctrl+Shift+T (v1.0): show/hide the live timer countdown from anywhere."""
    try:
        if _wivexx_gui:
            _wivexx_gui.toggle_timer_overlay()
    except Exception as e:
        _logger.warning("timer overlay toggle failed: %s", e)


def _hotkey_toggle_overlay() -> None:
    """Ctrl+Shift+O (v1.34): show/hide THE performance overlay (the transparent
    click-through OSD — the app's single overlay) from anywhere, incl. games."""
    try:
        if _wivexx_gui and hasattr(_wivexx_gui, "toggle_perf_osd"):
            _wivexx_gui.toggle_perf_osd(None)
    except Exception as e:
        _logger.warning("overlay toggle failed: %s", e)


def _osd_in_gaming_enabled() -> bool:
    """Settings → 'Performance overlay in optimized mode' (v1.34, default on)."""
    try:
        import wivexx_settings as _ws
        return bool(_ws.get("osd_in_gaming", True))
    except Exception:
        return True


def _hotkey_lightroom_import() -> None:
    """Ctrl+Shift+L (v1.11): newest Downloads folder → Lightroom Classic."""
    try:
        import wivexx_workmode
        msg = wivexx_workmode.import_latest_to_lightroom(cfg, logger=_logger)
        announce_async(msg)
    except Exception as e:
        _logger.warning("lightroom import failed: %s", e)


def _hotkey_toggle_recording() -> None:
    """v1.32: start/stop the action recorder from a global shortcut — works inside
    fullscreen games where the control-room panel can't be clicked. Stop trims the
    shortcut keys off the tail so playback doesn't re-press them."""
    try:
        import wivexx_macro as _mac
        st = _mac.status().get("state")
        res = _mac.toggle(trim_tail=True)
        if st == "recording":
            n = res.get("events", 0) if res.get("ok") else 0
            announce_async(f"Recording saved, {n} actions." if res.get("ok")
                           else "Nothing was recorded, sir.")
        elif res.get("ok"):
            announce_async(f"Recording, {res.get('mode', 'desktop')} mode.")
        else:
            announce_async("Couldn't start recording, sir.")
    except Exception as e:
        _logger.warning("toggle recording failed: %s", e)


def _hotkey_play_active() -> None:
    """Play (or stop) the 'active' recording from a global shortcut — fire a saved macro
    from anywhere, including inside a fullscreen game, without opening the panel."""
    try:
        import wivexx_macro as _mac
        st = _mac.status()
        if st.get("state") == "playing":
            _mac.stop_playback()
            announce_async("Stopped.")
            return
        res = _mac.play_active()
        if res.get("ok"):
            announce_async(f"Playing {res.get('name', 'recording')}.")
        else:
            announce_async(res.get("error") or "Couldn't play that, sir.")
    except Exception as e:
        _logger.warning("play active failed: %s", e)


def _hotkey_eyedropper() -> None:
    """Copy the hex of the pixel under the cursor to the clipboard (works over any
    app, including games in windowed/borderless mode). Shows a toast with the hex."""
    try:
        import wivexx_eyedropper as _eye

        def _notify(hexv: str) -> None:
            if _wivexx_gui is not None and hasattr(_wivexx_gui, "toast"):
                _wivexx_gui.toast(("copied " + hexv) if hexv else "couldn't read that colour")
        _eye.pick_under_cursor(notify=_notify)
    except Exception as e:
        _logger.warning("eyedropper failed: %s", e)


def _start_autoclicker() -> None:
    """v1.31: auto-clicker + multi-key auto-input (turbo) engine. Independent of
    the hotkey manager — its own SendInput output + GetAsyncKeyState trigger poll.
    Bound/configured from the GUI (Settings → autoclicker & inputs)."""
    try:
        import wivexx_autoclicker as _ac
        if _ac.start(announce=announce_async, logger=_logger,
                     mascot=_set_mascot_gadget):
            _cfg = _ac.get_config()
            _ac_cfg = _cfg.get("autoclicker", {})
            _n = len(_cfg.get("inputs", []))
            print("[AutoClick] Engine active (edit via Settings → autoclicker & inputs): "
                  f"autoclicker {_ac_cfg.get('hotkey') or '(unbound)'} "
                  f"@ {_ac_cfg.get('cps')} cps · {_n} key input(s)")
    except ImportError:
        pass
    except Exception as e:
        print(f"[AutoClick] engine error: {e}")


def _set_mascot_gadget(name: str) -> None:
    """Thread-safe, NON-BLOCKING forwarder: drive the orb's app-state pose
    (recording / clicker / playback). Runs the GUI/WebView2 call on a throwaway
    daemon thread so it can NEVER stall the caller — the auto-clicker and macro
    ENGINE threads call this, and a blocking evaluate_js once froze the clicker."""
    def _go():
        try:
            if _wivexx_gui is not None and hasattr(_wivexx_gui, "set_gadget"):
                _wivexx_gui.set_gadget(name or "")
        except Exception:
            pass
    try:
        threading.Thread(target=_go, daemon=True, name="wivexx-mascot-gadget").start()
    except Exception:
        pass


def _start_macro() -> None:
    """v1.31: keyboard + mouse action recorder / looping player. Records via
    pynput, replays via SendInput. Driven from the GUI (Actions → recorder).
    v1.32: drives the orb's camcorder/keyboard pose while recording/playing."""
    try:
        import wivexx_macro as _mac
        if _mac.start(logger=_logger, mascot=_set_mascot_gadget):
            print("[Macro] Action recorder ready "
                  f"(panic key: {_mac.get_panic_key()} — aborts playback)")
    except ImportError:
        pass
    except Exception as e:
        print(f"[Macro] recorder error: {e}")


def _start_hotkeys() -> None:
    # ── v1.6: customisable multi-key shortcut manager with watchdog ───────────
    try:
        import wivexx_hotkeys as _hk
        if _hk.init(
            actions={
                "toggle_mic":       _toggle_mic_mute,
                "toggle_voice":     _toggle_voice_mute,
                "toggle_pause":     _toggle_audio_mute,
                "push_to_talk":     _ptt_press,
                "toggle_call_mode": _toggle_call_mode,
                "toggle_goals_overlay": _hotkey_toggle_goals_overlay,
                "toggle_timers":    _hotkey_toggle_timer_overlay,
                "lightroom_import": _hotkey_lightroom_import,
                "toggle_recording": _hotkey_toggle_recording,
                "play_active":      _hotkey_play_active,
                "eyedropper":       _hotkey_eyedropper,
                "toggle_overlay":   _hotkey_toggle_overlay,
            },
            run_command=_run_hotkey_command,
            logger=_logger,
        ):
            _b = _hk.get_bindings()
            print("[Hotkeys] Shortcuts active (edit via GUI Shortcuts):")
            for _a, _label in _hk.ACTION_LABELS.items():
                print(f"           {_b.get(_a, '—'):<18} {_label}")
            return
    except ImportError:
        pass
    except Exception as e:
        print(f"[Hotkeys] manager error: {e}")

    # ── Legacy fallback: plain keyboard-library bindings ──────────────────────
    try:
        import keyboard as _kb
        _kb.add_hotkey("ctrl+shift+m", _toggle_mic_mute,  suppress=False)
        _kb.add_hotkey("ctrl+shift+v", _toggle_audio_mute, suppress=False)
        print("[Hotkeys] Ctrl+Shift+M = mic mute  |  Ctrl+Shift+V = voice pause/resume")
        return
    except ImportError:
        pass
    except Exception as e:
        print(f"[Hotkeys] keyboard library error: {e}")

    if not _HAS_PYNPUT:
        print("[Hotkeys] No hotkey library available — use tray menu for mute controls.")
        return

    _ctrl  = [False]
    _shift = [False]

    def _is_ctrl(k):
        try:
            return k in (_pynput_kb.Key.ctrl, _pynput_kb.Key.ctrl_l, _pynput_kb.Key.ctrl_r)
        except Exception:
            return False

    def _is_shift(k):
        try:
            return k in (_pynput_kb.Key.shift, _pynput_kb.Key.shift_l, _pynput_kb.Key.shift_r)
        except Exception:
            return False

    def _on_press(key):
        if _is_ctrl(key):
            _ctrl[0] = True
            return
        if _is_shift(key):
            _shift[0] = True
            return
        if not (_ctrl[0] and _shift[0]):
            return
        try:
            ch = key.char.lower() if hasattr(key, "char") and key.char else None
        except AttributeError:
            ch = None
        if ch == "m":
            _toggle_mic_mute()
        elif ch == "v":
            _toggle_audio_mute()

    def _on_release(key):
        if _is_ctrl(key):
            _ctrl[0] = False
        elif _is_shift(key):
            _shift[0] = False

    try:
        listener = _pynput_kb.Listener(on_press=_on_press, on_release=_on_release)
        listener.daemon = True
        listener.start()
        print("[Hotkeys] (pynput fallback) Ctrl+Shift+M / Ctrl+Shift+V registered")
    except Exception as e:
        print(f"[Hotkeys] Could not register: {e}  (use tray menu instead)")


def _print_startup_summary() -> None:
    calib     = _load_calibration()
    mic_idx   = calib.get("device_index", "?")
    mic_name  = calib.get("device_name", "default")
    tts_voice = cfg.VOICE if _HAS_EDGE_TTS else "pyttsx3 (edge-tts unavailable)"

    c = _C["cyan"]
    r = _C["reset"]
    b = _C["bold"]

    if _RICH_CONSOLE:
        from rich.panel import Panel
        _RICH_CONSOLE.print(Panel(
            f"[bold cyan]W I V E X X[/]\n"
            f"Your AI assistant  [dim]v{WIVEXX_VERSION}[/]",
            border_style="cyan",
        ))
    else:
        print(f"{c}{b}")
        print("  +======================================+")
        print("  |             W I V E X X              |")
        print("  |          Your AI assistant           |")
        print(f"  |               v{WIVEXX_VERSION}                  |")
        print(f"  +======================================+{r}")

    print(f"     Model  : {cfg.MODEL}")
    print(f"     Voice  : {tts_voice}")
    print(f"     Wake   : {cfg.WAKE_WORD.title()}")
    print(f"     Mic    : [{mic_idx}] {mic_name}")
    print("     Status : All systems operational")
    print()


def _graceful_shutdown(signum=None, frame=None) -> None:
    _logger.info("Shutdown signal received — cleaning up.")
    try:
        if _HAS_EDGE_TTS:
            pygame.mixer.music.stop()
            pygame.mixer.quit()
    except Exception:
        pass
    try:
        if _tray_icon:
            _tray_icon.stop()
    except Exception:
        pass
    speak("Shutting down. Goodbye, sir.")
    sys.exit(0)


def _init_wivexx() -> None:
    """One-time initialisation: signals, tray, hotkeys, mic test, startup speech.
    Must complete before the background loop or GUI starts."""
    global _session_start
    _session_start = time.time()

    try:
        signal.signal(signal.SIGTERM, _graceful_shutdown)
    except (OSError, ValueError):
        pass

    # v1.10: was defined-but-never-called since v1.3.1 — the schema migration
    # (importance/source/usage columns) needs it to actually run at boot.
    _init_memory_db()

    _migrate_old_conversations()

    if _HAS_TRAY:
        threading.Thread(target=_run_tray, daemon=True).start()
        time.sleep(0.4)

    # Autostart with Windows — the Settings toggle ("autostart") overrides the
    # AUTOSTART_ENABLED config default; _apply_autostart writes/removes the launcher.
    try:
        import wivexx_settings as _ws_boot
        _auto = _ws_boot.get("autostart", None)
        if _auto is None:
            _auto = bool(getattr(cfg, "AUTOSTART_ENABLED", True))
        _apply_autostart(bool(_auto))
    except Exception as e:
        _logger.warning(f"Autostart registration failed: {e}")

    _start_hotkeys()
    _start_autoclicker()
    _start_macro()

    # ── v1.6: performance guard — auto low-power mode under heavy load ────────
    try:
        import wivexx_perf
        wivexx_perf.start(
            cfg,
            on_low_power=lambda: _set_power_mode(True),
            on_full_power=lambda: _set_power_mode(False),
            logger=_logger,
            activity_getter=lambda: _CURRENT_ACTIVITY,
        )
    except Exception as _perf_e:
        _logger.warning("Performance monitor unavailable: %s", _perf_e)

    # ── v1.30: live telemetry sampler feeding the Performance HUD ─────────────
    try:
        import wivexx_sysmon
        wivexx_sysmon.start(1.2)
    except Exception as _sm_e:
        _logger.warning("System monitor (HUD telemetry) unavailable: %s", _sm_e)

    # ── v1.30: gaming-mode crash recovery — thaw any apps a prior run left frozen
    try:
        import wivexx_gaming_mode
        wivexx_gaming_mode.recover(logger=_logger)
    except Exception as _gm_e:
        _logger.warning("Gaming-mode recovery skipped: %s", _gm_e)

    # ── v1.22: state watchdog — heals a sphere stuck on the wrong animation ────
    threading.Thread(target=_state_watchdog_loop, daemon=True,
                     name="wivexx-state-watchdog").start()

    # mascot reacts to you copying text (privacy-safe: sequence number only)
    threading.Thread(target=_clipboard_react_loop, daemon=True,
                     name="wivexx-clip-react").start()
    # mascot wears a fitting outfit for what you're doing (music/gaming/video/coding)
    threading.Thread(target=_activity_detect_loop, daemon=True,
                     name="wivexx-activity-detect").start()
    # publish live state (activity/stats/goals) to the interactive desktop wallpaper
    threading.Thread(target=_wallpaper_state_loop, daemon=True,
                     name="wivexx-wallpaper-state").start()

    # ── v1.8.1: non-interrupting announcement drainer + disk usage monitor ────
    threading.Thread(target=_announce_drainer, daemon=True,
                     name="wivexx-announce").start()
    try:
        import wivexx_notify_prefs as _np
        _np.set_logger(_logger)
    except Exception:
        pass
    try:
        import wivexx_disk
        # v1.32: gated live by the "drive almost full" recurring-notification toggle
        wivexx_disk.start_usage_monitor(
            cfg, announce=lambda m: _notify_announce("drive_full", m),
            logger=_logger)
    except Exception as _disk_e:
        _logger.warning("Disk monitor unavailable: %s", _disk_e)

    # ── v1.9: goal reminders (hourly/daily/weekly, non-interrupting) ──────────
    _goals_mod = None
    try:
        import wivexx_goals as _goals_mod

        def _goal_alarm(title: str) -> None:
            if not _notify_enabled("goal_reminders"):
                return
            try:
                if _wivexx_gui is not None:
                    _wivexx_gui.fire_goal_alert(title)   # visible beeping alarm
            except Exception:
                pass
        # v1.32: gated live by the "goal reminders" recurring-notification toggle
        _goals_mod.start_reminders(
            lambda m: _notify_announce("goal_reminders", m),
            logger=_logger, alarm=_goal_alarm)
    except Exception as _goals_e:
        _goals_mod = None
        _logger.warning("Goals system unavailable: %s", _goals_e)

    # ── v1.12: social media trackers — SILENT once-per-session refresh ────────
    # v1.31/v1.32: the spoken boot briefing is OFF by default and is now a
    # toggleable recurring notification (Activity → recurring notifications). The
    # social stats live in the Social hub, so we refresh the data quietly and only
    # speak the briefing if the user has switched it back on.
    _socials_mod = None
    try:
        import wivexx_socials as _socials_mod
        _socials_mod.start_session_briefing(announce_async, logger=_logger,
                                            speak=_notify_enabled("social_brief"))
    except Exception as _soc_e:
        _socials_mod = None
        _logger.warning("Social trackers unavailable: %s", _soc_e)

    # ── v1.14: client photo delivery engine (ARW→JPG develop + Drive) ─────────
    _deliver_mod = None
    try:
        import wivexx_deliver as _deliver_mod
        _deliver_mod.set_logger(_logger)
    except Exception as _dlv_e:
        _deliver_mod = None
        _logger.warning("Delivery engine unavailable: %s", _dlv_e)

    # ── v1.15: quick local tools — share the logger ───────────────────────────
    try:
        if _calc_mod is not None:
            _calc_mod.set_logger(_logger)
        import wivexx_winctl as _wc_mod
        _wc_mod.set_logger(_logger)
        import wivexx_calendar as _cal_mod
        _cal_mod.set_logger(_logger)
    except Exception:
        pass

    # ── v1.17: voice biometrics + AEC + screen UI ─────────────────────────────
    try:
        import wivexx_voiceid as _vid_mod
        _vid_mod.set_logger(_logger)
        _vid_mod.configure(threshold=getattr(cfg, "VOICEID_THRESHOLD", 0.75))
        if _vid_mod.enrolled():
            _logger.info("Voice ID enrolled (sensitive-command voice gating is OFF).")
    except Exception:
        pass

    # ── v1.57 Tier 3: dedicated ACOUSTIC wake-word detector (openWakeWord) ──────
    # Fully optional + fail-safe: if the package/models aren't present the module's
    # ready() stays False and the wake loop uses the improved Whisper path (no
    # regression). load() downloads the tiny ONNX models on first use + builds the
    # session, so it runs on a background thread and never blocks boot.
    try:
        import wivexx_wakeword as _ww_mod
        _ww_mod.set_logger(_logger)
        _ww_mod.configure(threshold=getattr(cfg, "WAKE_ACOUSTIC_THRESHOLD", 0.5))
        global _wakeword_mod
        _wakeword_mod = _ww_mod
        if _wake_acoustic_enabled() and _ww_mod.available():
            threading.Thread(target=_ww_mod.load, daemon=True,
                             name="wivexx-wakeword-load").start()
    except Exception as _ww_e:
        _logger.debug("openWakeWord init skipped (%s)", _ww_e)
    try:
        if _aec_mod is not None:
            _aec_mod.set_logger(_logger)
            _aec_mod.configure(
                latency_ms=getattr(cfg, "AEC_LATENCY_MS", 180),
                gating=getattr(cfg, "AEC_GATING", True),
                factor=getattr(cfg, "AEC_FACTOR", 0.8),
                enabled=getattr(cfg, "AEC_ENABLED", False))
        import wivexx_screenui as _su_mod
        _su_mod.set_logger(_logger)
    except Exception:
        pass

    # ── v1.11: work mode (zip auto-extract + Lightroom import shortcut) ───────
    # Niche photographer feature — gated off in the public build (WORK_MODE_AVAILABLE).
    _workmode_mod = None
    if getattr(cfg, "WORK_MODE_AVAILABLE", True):
        try:
            import wivexx_workmode as _workmode_mod
            _workmode_mod.start(cfg, announce=announce_async, logger=_logger)
        except Exception as _wm_e:
            _workmode_mod = None
            _logger.warning("Work mode unavailable: %s", _wm_e)

    # ── v1.7/v1.8: phone / browser interface ──────────────────────────────────
    _memory_mod = None
    try:
        import wivexx_memory_store as _memory_mod
        _memory_mod.set_logger(_logger)
        if getattr(cfg, "MEMORY_SEMANTIC", True):
            _memory_mod.set_embedder(_ollama_embed)
            _memory_mod.configure_semantic(
                weight=getattr(cfg, "MEMORY_SEM_WEIGHT", None),
                threshold=getattr(cfg, "MEMORY_SEM_THRESHOLD", None))

            def _embed_backfill_when_ready(mod=_memory_mod):
                for _ in range(20):           # wait for the async Ollama probe
                    if _OLLAMA_AVAILABLE:
                        break
                    time.sleep(1)
                try:
                    mod.embed_missing()
                except Exception:
                    pass
            threading.Thread(target=_embed_backfill_when_ready, daemon=True,
                             name="wivexx-embed-backfill").start()
    except Exception as _mem_e:
        _logger.warning("Semantic memory wiring skipped: %s", _mem_e)
    _web_mod = None
    try:
        import wivexx_web as _web_mod
        if _web_mod.start({
            "handle_message": _handle_remote_message,
            "handle_files":   _handle_remote_files,
            "att_meta":       _atts_meta,
            "screenshot":     _web_screenshot,
            "click":          _web_click,
            "type_text":      _web_type,
            "monitors":       _web_monitor_count,
            "get_state":      lambda: _current_state,
            "goals":          _goals_mod,
            "memory":         _memory_mod,
        }, cfg, logger=_logger):
            _host_url, _ip_url = _web_mod.get_urls(cfg)
            # IP first — phones can't resolve the Windows hostname (pcname:7777),
            # only the LAN IP works; the hostname is the local-machine fallback.
            print(f"[Web] Phone interface: {_ip_url}  (or {_host_url} on this PC)"
                  f"   PIN: {getattr(cfg, 'WEB_PIN', '')}")
        else:
            _web_mod = None
    except Exception as _web_e:
        _web_mod = None
        _logger.warning("Web interface unavailable: %s", _web_e)

    # ── v1.18: Wivexx online relay (wivexx.com/ai drives this PC) ─────────────
    # Reuses the SAME remote pipeline as the phone page, so every command works.
    # Fail-soft: stays inert unless RELAY_ENABLED + the Supabase env vars are set.
    try:
        import wivexx_relay as _relay_mod
        _relay_mod.start(_handle_remote_message, cfg, logger=_logger)
    except Exception as _relay_e:
        _logger.warning("Online relay unavailable: %s", _relay_e)

    # ── v1.34/v1.55: hands-free updates (Settings → 'Auto-update on startup') ──
    # When enabled: shortly after boot AND every ~3h after, check wivexx.com; if a
    # newer build exists AND it's a SAFE moment (the user isn't mid-call, in a
    # performance session, being spoken to, or recording), snapshot the active modes,
    # download + stage the update (user data is never touched) and relaunch. The next
    # boot silently restores those modes, so the whole update is unnoticeable. At a
    # busy moment we DEFER to the next cycle rather than interrupt the user.
    def _auto_update_boot() -> None:
        import wivexx_settings as _ws
        import wivexx_update as _up
        first = True
        while True:
            try:
                time.sleep(25 if first else 3 * 3600)   # settle at boot, then ~every 3h
                first = False
                if not _ws.get("auto_update", False):
                    continue
                info = _up.check()
                if not (info.get("ok") and info.get("update")):
                    continue
                if not _update_is_safe_moment():
                    _logger.info("auto-update: v%s ready, but the user is busy — "
                                 "deferring to the next check.", info.get("latest"))
                    continue
                _logger.info("auto-update: v%s -> v%s — downloading…",
                             info.get("current"), info.get("latest"))
                zip_path = _up.download(info.get("url"))
                _write_restore_state("update")   # remember modes → seamless relaunch
                r = _up.apply_update(zip_path)
                if r.get("ok"):
                    _logger.info("auto-update: staged — restarting to apply (modes saved).")
                    time.sleep(1.2)              # let the bat arm
                    os._exit(0)                  # updater swaps files and relaunches
                _logger.warning("auto-update staging failed: %s", r.get("error"))
            except Exception as _au_e:
                _logger.warning("auto-update cycle failed: %s", _au_e)

    # restore any modes saved right before a prior auto-update relaunch (seamless)
    try:
        _restore_modes_if_pending()
    except Exception:
        pass
    threading.Thread(target=_auto_update_boot, daemon=True,
                     name="wivexx-auto-update").start()

    # ── v1.7: external storage auto-import ────────────────────────────────────
    _media_mod = None
    try:
        import wivexx_media_import as _media_mod
        _media_mod.start(
            cfg, speak_fn=_speak_for_import,
            prompt_fn=(_wivexx_gui.prompt_media_device if _wivexx_gui else None),
            logger=_logger,
        )
    except Exception as _media_e:
        _logger.warning("Media auto-import unavailable: %s", _media_e)

    # ── v1.6: hand the GUI everything it needs for the editors / call mode ────
    if _wivexx_gui:
        try:
            _hk_mod = None
            try:
                import wivexx_hotkeys as _hk_mod
            except Exception:
                pass
            _disk_mod = None
            try:
                import wivexx_disk as _disk_mod
            except Exception:
                pass
            _memory_mod = None
            try:
                import wivexx_memory_store as _memory_mod
            except Exception:
                pass
            _wivexx_gui.set_integrations({
                "media":            _media_mod,
                "disk":             _disk_mod,
                "goals":            _goals_mod,
                "socials":          _socials_mod,
                # v1.14: client photo delivery engine (ARW→JPG + Drive)
                "deliver":          _deliver_mod,
                # v1.16: live data feeds for the status overlay
                "get_timers":       _list_active_timers,
                "cancel_timer":     _cancel_timer,
                "get_shutdown":     _shutdown_info,
                "cancel_shutdown":  _cancel_shutdown,
                "get_usage":        _usage_today,
                # v1.13: Caption Studio engine + helpers
                "caption_generate": _caption_generate,
                "video_frames":     _video_frames_b64,
                "encode_image":     _encode_image_file,
                "memory":           _memory_mod,
                "workmode":         _workmode_mod,
                "research_goal":    _research_goal,
                "web_clients":      (_web_mod.get_clients if _web_mod else None),
                "apps_json_path":   _APPS_JSON_PATH,
                "reload_apps":      _load_apps_json,
                "toggle_call_mode": _toggle_call_mode,
                "get_call_mode":    _get_call_mode,
                "toggle_gaming_mode": _toggle_gaming_mode,
                "get_gaming_mode":  _get_gaming_mode,
                "hotkeys":          _hk_mod,
                # v1.26: reload the speech model after a language switch so the
                # listener understands the newly-selected language.
                "reload_stt":       lambda: threading.Thread(
                    target=_load_stt_model, args=(_STT_PROFILE,),
                    daemon=True, name="wivexx-stt-reload").start(),
                # v1.58: after a language switch, offer the guided wake-word
                # tuning the FIRST time that language is used (per-language
                # wake profiles — see _enroll_wake_profile).
                "wake_enroll_check": lambda code: threading.Thread(
                    target=_wake_enroll_offer, args=(code,),
                    daemon=True, name="wivexx-wake-enroll").start(),
                # v1.29: Settings menu — apply-on-toggle effects
                "apply_autostart":     _apply_autostart,
                "apply_low_resource":  _apply_low_resource,
                "log_request":      lambda text: _log_feature_request(text, source="gui"),
                # v1.6.2: typed messages (+ optional attached image) from the GUI
                "submit_text":      lambda text, image=None: threading.Thread(
                    target=_submit_typed, args=(text, image),
                    daemon=True, name="wivexx-typed-input").start(),
            })
        except Exception as _int_e:
            _logger.debug("GUI integrations not set: %s", _int_e)

    _run_mic_test()
    _print_startup_summary()

    set_tray("standby", "Wivexx — standby")
    # v1.7.1: greeting runs on a daemon thread — TTS must NEVER be able to
    # block the boot path (a stalled output device once froze the greeting
    # forever, so the GUI window and wake-word loop never started).
    # The startup greeting is a toggleable recurring notification. v1.34: it is
    # now controlled SOLELY by the Notifications panel (boot_greeting) — the
    # duplicate Settings → "startup greeting" toggle was removed.
    if _notify_enabled("boot_greeting"):
        threading.Thread(
            target=speak,
            args=(f"Wivexx version {WIVEXX_VERSION} online. "
                  f"All systems operational, sir.",),
            daemon=True, name="wivexx-greeting",
        ).start()
    print("  Say 'hey wivexx' to activate.")
    print("  Speak while Wivexx is talking to interrupt him.")
    print("  Say 'stop' or 'stand down' to return to standby.")
    if _HAS_TRAY:
        print("  Right-click tray icon to quit or re-calibrate.")
    print("-" * 56)

    if _wivexx_gui:
        try:
            if hasattr(_wivexx_gui, 'set_memory_status'):
                _wivexx_gui.set_memory_status(_OLLAMA_AVAILABLE)
            elif hasattr(_wivexx_gui, 'set_memory_active'):
                _wivexx_gui.set_memory_active(_OLLAMA_AVAILABLE)
            elif hasattr(_wivexx_gui, 'update_memory_indicator'):
                _wivexx_gui.update_memory_indicator(_OLLAMA_AVAILABLE)
        except Exception as _mem_gui_e:
            _logger.debug("Could not update GUI memory status: %s", _mem_gui_e)


def _main_loop() -> None:
    """Blocking WIVEXX listen/respond loop.  Runs in a background daemon thread
    so that the main thread is free to drive the tkinter GUI."""
    # v1.58: first boot in a language with no wake profile → offer the guided
    # wake-word tuning once, before the wake loop takes the mic.
    try:
        import wivexx_i18n as _i18n_ml
        _wake_enroll_offer(_i18n_ml.current_language())
    except Exception:
        pass
    while True:
        set_tray("standby", "Wivexx — standby")
        _listen_for_wake_word()
        _active_session()


# Keep _main() as a legacy alias for callers / test scripts that invoke it directly.
def _main() -> None:
    _init_wivexx()
    _main_loop()


if __name__ == "__main__":
    try:
        _init_wivexx()

        # ── Voice loop runs in a background daemon thread ────────────────────────────────────
        _bg = threading.Thread(target=_main_loop, daemon=True, name="wivexx-loop")
        _bg.start()

        # ── GUI runs on the MAIN thread (tkinter requirement on Windows) ──────
        if _wivexx_gui and getattr(cfg, "GUI_ENABLED", True):
            _gui_cfg = {
                "GUI_WIDTH":           getattr(cfg, "GUI_WIDTH",          600),
                "GUI_HEIGHT":          getattr(cfg, "GUI_HEIGHT",         700),
                "GUI_SPHERE_POINTS":   getattr(cfg, "GUI_SPHERE_POINTS",  70),
                "GUI_RING_SPAWN_RATE": getattr(cfg, "GUI_RING_SPAWN_RATE",2.0),
                "GUI_FPS":             getattr(cfg, "GUI_FPS",            30),
                "GUI_ENABLED":         getattr(cfg, "GUI_ENABLED",        True),
                "GUI_DOT_3D":          getattr(cfg, "GUI_DOT_3D",         True),
                "GUI_DOT_MAX_PX":      getattr(cfg, "GUI_DOT_MAX_PX",     320),
                "GUI_DOT_GLOW":        getattr(cfg, "GUI_DOT_GLOW",       True),
                # v1.20: web (WebView2) UI engine + window size
                "GUI_ENGINE":          getattr(cfg, "GUI_ENGINE",        "web"),
                "GUI_WINDOW_W":        getattr(cfg, "GUI_WINDOW_W",       980),
                "GUI_WINDOW_H":        getattr(cfg, "GUI_WINDOW_H",       720),
                "GUI_WEB_DEBUG":       getattr(cfg, "GUI_WEB_DEBUG",     False),
            }
            try:
                _wivexx_gui.run_gui_mainloop(_gui_cfg)   # blocks until window closed
            except Exception as _gui_e:
                _logger.warning("GUI mainloop error: %s", _gui_e)
                _close_splash()   # ensure the splash never hangs if the GUI fails
                _bg.join()   # no GUI -- just keep the background loop alive
        else:
            _close_splash()   # GUI disabled -- dismiss the splash
            _bg.join()   # GUI disabled -- keep process alive via background thread

    except KeyboardInterrupt:
        _graceful_shutdown()
    except Exception as _e:
        import traceback
        traceback.print_exc()
