(() => {
  if (window.__ritsVoiceBridgeV30StopRepeatStripInstalled) {
    return;
  }
  window.__ritsVoiceBridgeV30StopRepeatStripInstalled = true;

  const SPEAKER_URL = "http://127.0.0.1:5055/speak";
  const STOP_URL = "http://127.0.0.1:5055/stop";
  const TRANSCRIBE_URL = "http://127.0.0.1:5055/transcribe";

  const SITES = [
    {
      id: "chatgpt",
      label: "ChatGPT",
      hosts: ["chatgpt.com", "chat.openai.com"]
    },
    {
      id: "claude",
      label: "Claude",
      hosts: ["claude.ai"],
      inputSelectors: [
        'div[contenteditable="true"]',
        'textarea',
        '[role="textbox"]'
      ],
      assistantSelectors: [
        '[data-testid="conversation-turn-assistant"]',
        '[data-testid*="assistant"]',
        '[class*="font-claude-message"]',
        '.font-claude-message',
        '[class*="claude-message"]',
        'div.markdown',
        '[class*="markdown"]',
        'article'
      ],
      ignoreText: ["claude can make mistakes", "copy", "retry", "share"]
    },
    {
      id: "deepseek",
      label: "DeepSeek",
      hosts: ["chat.deepseek.com"],
      inputSelectors: [
        'textarea',
        'div[contenteditable="true"]',
        '[role="textbox"]'
      ],
      assistantSelectors: [
        '.ds-markdown',
        '[class*="ds-markdown"]',
        'main .ds-markdown',
        'main [class*="ds-markdown"]',
        'main [data-role="assistant"]',
        'main [data-message-author-role="assistant"]'
      ],
      ignoreText: ["deepseek", "copy", "regenerate", "retry"]
    },
    {
      id: "gemini",
      label: "Gemini",
      hosts: ["gemini.google.com"],
      inputSelectors: [
        'rich-textarea',
        'bard-text-input',
        'div[aria-label*="Ask Gemini" i]',
        'div[data-placeholder*="Ask Gemini" i]',
        'rich-textarea div[contenteditable]',
        'div.ql-editor[contenteditable]',
        '.ql-editor[contenteditable]',
        '.textarea[contenteditable]',
        '[contenteditable="plaintext-only"]',
        'div[contenteditable="true"][aria-label*="Enter a prompt" i]',
        'div[contenteditable="true"][aria-label*="Ask Gemini" i]',
        '[role="textbox"][aria-label*="Enter a prompt" i]',
        '[role="textbox"][aria-label*="Ask Gemini" i]',
        '[role="textbox"]',
        'div[contenteditable]',
        'textarea'
      ],
      assistantSelectors: [
        'message-content',
        'main message-content',
        '[id^="model-response-message-content"]',
        '[data-response-index]',
        '[data-test-id*="response"]',
        '.model-response-text',
        'main [class*="markdown"]',
        'main article',
        'article'
      ],
      ignoreText: ["gemini can make mistakes", "google", "copy", "export", "share", "retry", "more", "listen"]
    }

  ];

  const SITE = SITES.find(site => site.hosts.includes(location.hostname));
  if (!SITE) {
    return;
  }

  function cleanText(text) {
    return (text || "")
      .replace(/\r/g, "")
      .replace(/^Thought for[^\n]*[›>]?\s*/i, "")
      .replace(/\n{3,}/g, "\n\n")
      .trim();
  }

  function cleanForTts(text) {
    text = (text || "");

    const replacements = [
      [/â€™/g, "'"],
      [/â€˜/g, "'"],
      [/â€œ/g, '"'],
      [/â€�/g, '"'],
      [/â€/g, '"'],
      [/â€/g, '-'],
      [/â€“/g, '-'],
      [/â€¦/g, '...'],
      [/â„¢/g, ''],
      [/Â /g, ' '],
      [/Â/g, ''],
      [/‘/g, "'"],
      [/’/g, "'"],
      [/‚/g, "'"],
      [/‛/g, "'"],
      [/“/g, '"'],
      [/”/g, '"'],
      [/„/g, '"'],
      [/‟/g, '"'],
      [/–/g, '-'],
      [/—/g, '-'],
      [/―/g, '-'],
      [/−/g, '-'],
      [/…/g, '...'],
      [/ /g, ' '],
      [/™/g, ''],
      [/®/g, ''],
      [/©/g, ''],
      [/€/g, ''],
    ];

    for (const [bad, good] of replacements) {
      text = text.replace(bad, good);
    }

    // Speech polish for common product/version phrases.
    text = text
      .replace(/\bLET\'?S GO\b/g, "Let's go")
      .replace(/\bLets go\b/gi, "Let's go")
      .replace(/\bv(\d+)\.(\d+)\b/gi, "version $1 point $2");

    // Final Windows TTS safety net: keep speech text plain ASCII.
    // This prevents Windows voices from reading mojibake symbols as "euro" / "trademark".
    return text
      .normalize('NFKD')
      .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '')
      .replace(/[ \t]{2,}/g, ' ')
      .trim();
  }

  function normalize(text) {
    return cleanText(text)
      .replace(/\s+/g, " ")
      .replace(/[“”]/g, '"')
      .replace(/[‘’]/g, "'")
      .trim()
      .toLowerCase();
  }

  function fingerprint(text) {
    return normalize(text).slice(0, 500);
  }

  function cleanCandidateText(text) {
    const dropExact = new Set([
      "copy",
      "copied",
      "retry",
      "regenerate",
      "regenerate response",
      "share",
      "edit",
      "like",
      "dislike",
      "listen",
      "read aloud",
      "google",
      "gemini",
      "stop",
      "send",
      "sources",
      "show thinking",
      "hide thinking",
      "thinking",
      "analyzing",
      "loading"
    ]);

    return cleanText(text)
      .split("\n")
      .map(line => line.trim())
      .filter(line => {
        const n = normalize(line);
        if (!n) return false;
        if (dropExact.has(n)) return false;
        if (/^[.\s•·●○◌◍◦]+$/.test(n)) return false;
        return true;
      })
      .join("\n")
      .trim();
  }


  function looksLikeDeepSeekThoughtLine(line) {
    const n = normalize(line || "");
    if (!n) return true;

    return (
      /^thought for\b/.test(n) ||
      /^\d+\.\s/.test(n) ||
      /^[-•]\s/.test(n) ||
      n.startsWith("analyze the user") ||
      n.startsWith("analyze user's") ||
      n.startsWith("analyze user") ||
      n.startsWith("the user's input") ||
      n.startsWith("the user's query is") ||
      n.startsWith("the user just said") ||
      n.startsWith("the user is") ||
      n.startsWith("determine persona") ||
      n.startsWith("persona/response strategy") ||
      n.startsWith("response strategy") ||
      n.startsWith("maintain the") ||
      n.startsWith("tone is") ||
      n.startsWith("the tone is") ||
      n.startsWith("acknowledge") ||
      n.startsWith("interpret") ||
      n.startsWith("they might be") ||
      n.startsWith("they just want") ||
      n.startsWith("i need to") ||
      n.startsWith("i should") ||
      n.startsWith("my response should") ||
      n.startsWith("keep it ") ||
      n.startsWith("so i need") ||
      n.startsWith("the intent is") ||
      n.startsWith("the question is")
    );
  }


  function isDeepSeekReasoningText(text) {
    const cleaned = cleanCandidateText(text || "");
    if (!cleaned) return false;

    const n = normalize(cleaned);
    const lines = cleaned.split("\n").map(line => line.trim()).filter(Boolean);
    const first = normalize(lines[0] || "");

    if (/^thought for\b/.test(n)) return true;
    if (/^thought for\b/.test(first)) return true;

    const signals = [
      "analyze the user's input",
      "analyze the user",
      "input:",
      "user input:",
      "determine the goal",
      "determine goal",
      "determine the persona",
      "determine persona",
      "persona and tone",
      "formulate the response",
      "formulate response",
      "final review",
      "final polish",
      "safety and policy",
      "response strategy",
      "maintain the",
      "acknowledge the",
      "answer the question",
      "return the well-wish",
      "match the tone",
      "keep it concise",
      "ask for clarification",
      "options for clarification",
      "offer to assist",
      "turn it back to the user",
      "avoid being robotic",
      "use emojis sparingly",
      "the user is asking",
      "the user is greeting",
      "the user said",
      "this is a continuation",
      "consistent with previous turns"
    ];

    if (signals.some(signal => n.includes(signal))) return true;

    const numberedHeadings = lines.filter(line => /^\d+\.\s+[^:]{2,80}:/.test(line)).length;
    const bulletAnalysis = lines.filter(line => /^[•\-o]\s+/.test(line) || /^○\s+/.test(line)).length;
    const quotedExplanation = lines.filter(line => /^["“].+["”]\s*[-–—:]/.test(line)).length;

    return (
      numberedHeadings >= 1 ||
      bulletAnalysis >= 2 ||
      quotedExplanation >= 1
    );
  }

  function cleanDeepSeekAssistantText(text) {
    const cleaned = cleanCandidateText(text || "");
    if (!cleaned) return "";

    const lines = cleaned
      .split("\n")
      .map(line => line.trim())
      .filter(Boolean);

    if (!lines.length) return "";

    const joinedNorm = normalize(lines.join("\n"));
    const firstNorm = normalize(lines[0] || "");

    const reasoningBlockSignals = [
      "input:",
      "user input:",
      "analyze the user's input",
      "analyze the user",
      "analyze user's",
      "determine the persona",
      "determine persona",
      "determine the ai persona",
      "ai persona",
      "persona and tone",
      "persona/tone",
      "persona/response strategy",
      "response strategy",
      "formulate the response",
      "formulate response",
      "formulate the reply",
      "craft the response",
      "final polish",
      "final response should",
      "maintain the",
      "keep it concise",
      "acknowledge the",
      "answer the",
      "turn it back to the user",
      "offer to assist",
      "end by offering",
      "respond with",
      "the tone is",
      "tone is",
      "the user is asking",
      "the user is greeting",
      "the user said",
      "this is a continuation",
      "consistent with previous turns",
      "match the user's",
      "match the users",
      "avoid being robotic",
      "use emojis sparingly"
    ];

    const firstLineIsReasoning = (
      /^\d+\.\s/.test(firstNorm) ||
      /^[-•]\s/.test(firstNorm) ||
      firstNorm.startsWith("input:") ||
      firstNorm.startsWith("user input:") ||
      firstNorm.startsWith("respond ") ||
      firstNorm.startsWith("respond with") ||
      firstNorm.startsWith("end by") ||
      firstNorm.startsWith("offer ") ||
      reasoningBlockSignals.some(signal => firstNorm.includes(signal))
    );

    const signalCount = reasoningBlockSignals.filter(signal => joinedNorm.includes(signal)).length;

    if (firstLineIsReasoning || signalCount >= 1) {
      return "";
    }

    const thoughtIndex = lines.findIndex(line => /^thought for\b/i.test(line));
    if (thoughtIndex !== -1) {
      let endThoughtIndex = thoughtIndex;

      for (let i = thoughtIndex + 1; i < lines.length; i++) {
        if (looksLikeDeepSeekThoughtLine(lines[i])) {
          endThoughtIndex = i;
          continue;
        }
        break;
      }

      const postThought = lines.slice(endThoughtIndex + 1).join("\n").trim();
      return cleanCandidateText(postThought);
    }

    if (looksLikeDeepSeekThoughtLine(lines[0])) {
      return "";
    }

    return cleaned;
  }


  function isJunkText(text) {
    const t = normalize(text);

    return (
      !t ||
      t.length < 2 ||
      t === "thinking" ||
      t.startsWith("thinking") ||
      t.startsWith("thought for") ||
      t === "analyzing image" ||
      t.startsWith("analyzing image") ||
      t === "analyzing images" ||
      t.startsWith("analyzing images") ||
      t === "analyzing" ||
      t.startsWith("analyzing") ||
      t === "working" ||
      t.startsWith("working") ||
      t === "loading" ||
      t.startsWith("loading") ||
      t === "searching" ||
      t.startsWith("searching") ||
      t === "updating memory" ||
      t.startsWith("updating memory") ||
      /^[.\s•·●○◌◍◦]+$/.test(t)
    );
  }


  function cleanGeminiAssistantText(text) {
    const cleaned = cleanCandidateText(text || "");
    if (!cleaned) return "";

    const rawLines = cleaned
      .split("\n")
      .map(line => line.trim())
      .filter(Boolean);

    if (!rawLines.length) return "";

    function labelOf(line) {
      const n = normalize(line).replace(/[:：]\s*$/, "");
      if (n === "you said") return "user";
      if (n === "gemini said") return "assistant";
      if (/^you said[:：]/i.test(line)) return "user-inline";
      if (/^gemini said[:：]/i.test(line)) return "assistant-inline";
      return "";
    }

    let lastUserLabel = -1;
    let lastAssistantLabel = -1;

    for (let i = 0; i < rawLines.length; i++) {
      const label = labelOf(rawLines[i]);
      if (label === "user" || label === "user-inline") lastUserLabel = i;
      if (label === "assistant" || label === "assistant-inline") lastAssistantLabel = i;
    }

    // Gemini exposes accessibility transcript wrappers like:
    // "You said ... Gemini said ... You said ..."
    // If the newest label is "You said", the current answer has not appeared yet.
    if (lastUserLabel !== -1 || lastAssistantLabel !== -1) {
      if (lastAssistantLabel === -1) return "";
      if (lastUserLabel > lastAssistantLabel) return "";

      const startLine = rawLines[lastAssistantLabel];
      const startLabel = labelOf(startLine);
      const pieces = [];

      if (startLabel === "assistant-inline") {
        const inline = startLine.replace(/^gemini said[:：]?\s*/i, "").trim();
        if (inline) pieces.push(inline);
      }

      for (let i = lastAssistantLabel + 1; i < rawLines.length; i++) {
        const label = labelOf(rawLines[i]);
        if (label === "user" || label === "user-inline" || label === "assistant" || label === "assistant-inline") break;
        pieces.push(rawLines[i]);
      }

      return cleanCandidateText(pieces.join("\n"));
    }

    return cleaned.replace(/^gemini said[:：]?\s*/i, "").trim();
  }

  function getGeminiAssistantTextCandidates() {
    const searchRoot = document.querySelector("main") || document.body || document;
    const selectors = [
      'message-content',
      'main message-content',
      '[id^="model-response-message-content"]',
      '.model-response-text',
      '[data-test-id*="response"]'
    ];

    let elements = [];

    for (const selector of selectors) {
      try {
        elements = elements.concat(querySelectorAllDeep(selector, searchRoot));
      } catch (_) {}
    }

    elements = uniqueElements(elements)
      .filter(el => el && isVisible(el))
      .filter(el => !el.closest?.("nav, header, aside, form, textarea, input, button, [role='button'], [role='navigation']"));

    const candidates = elements
      .map(el => {
        const rect = el.getBoundingClientRect?.();
        const text = cleanGeminiAssistantText(el.innerText || el.textContent || "");
        return { el, text, bottom: rect?.bottom || 0, top: rect?.top || 0 };
      })
      .filter(item => item.text && !isJunkText(item.text) && item.text.length < 30000)
      .sort((a, b) => {
        if (a.bottom !== b.bottom) return a.bottom - b.bottom;
        return a.text.length - b.text.length;
      });

    return candidates;
  }


  function isVisible(el) {
    if (!el || !(el instanceof Element)) return false;
    const style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
    const rect = el.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  }

  function uniqueElements(elements) {
    return Array.from(new Set(elements.filter(Boolean)));
  }

  function querySelectorAllDeep(selector, root = document) {
    const results = [];

    function collect(scope) {
      if (!scope || typeof scope.querySelectorAll !== "function") return;

      try {
        results.push(...Array.from(scope.querySelectorAll(selector)));
      } catch (_) {}

      // Gemini often nests the real editor inside custom elements. If a shadow root
      // is open, walk it. This is safe for other sites too, but only needed heavily
      // on Gemini.
      let all = [];
      try {
        all = Array.from(scope.querySelectorAll("*"));
      } catch (_) {}

      for (const el of all) {
        if (el?.shadowRoot) collect(el.shadowRoot);
      }
    }

    collect(root);
    return uniqueElements(results);
  }

  function getDeepActiveElement() {
    let active = document.activeElement;
    let guard = 0;
    while (active?.shadowRoot?.activeElement && guard < 10) {
      active = active.shadowRoot.activeElement;
      guard += 1;
    }
    return active || document.activeElement;
  }

  async function stopVoice(reason = "stop") {
    try {
      await fetch(STOP_URL, { method: "POST", cache: "no-store", keepalive: true });
      console.log(`[AI Voice Bridge] Voice stopped: ${reason}`);
    } catch (err) {
      try {
        await fetch(STOP_URL, { method: "POST", mode: "no-cors", cache: "no-store", keepalive: true });
      } catch (_) {}
      console.error("[AI Voice Bridge] Stop failed:", err);
    }
  }


  function stopVoiceOnPageExit() {
    try {
      if (navigator.sendBeacon) {
        const ok = navigator.sendBeacon(STOP_URL, new Blob([], { type: "text/plain" }));
        if (ok) return;
      }
    } catch (_) {}

    try {
      fetch(STOP_URL, {
        method: "POST",
        mode: "no-cors",
        cache: "no-store",
        keepalive: true
      }).catch(() => {});
    } catch (_) {}
  }

  window.addEventListener("pagehide", stopVoiceOnPageExit, { capture: true });
  window.addEventListener("beforeunload", stopVoiceOnPageExit, { capture: true });

  async function speakText(text) {
    const cleaned = cleanForTts(cleanText(text));
    if (!cleaned || isJunkText(cleaned)) return;

    setLastReadableText(cleaned);

    console.log("[AI Voice Bridge] ASCII cleanup active. Sending:", cleaned.slice(0, 160));

    try {
      await fetch(SPEAKER_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: cleaned, site: SITE.id, host: location.hostname })
      });

      console.log(`[AI Voice Bridge] ${SITE.label} spoke:`, cleaned.slice(0, 160));
    } catch (err) {
      console.error("[AI Voice Bridge] Speak failed:", err);
    }
  }

  function installHardEscStop() {
    let lastEscStopAt = 0;

    function hardEscHandler(event) {
      if (event.key === "Escape" || event.code === "Escape" || event.keyCode === 27) {
        const now = Date.now();
        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation();

        if (now - lastEscStopAt > 250) {
          lastEscStopAt = now;
          stopVoice("escape");
          markManualReadStopped();
        }
        return false;
      }
    }

    function add(target) {
      if (!target) return;
      target.addEventListener("keydown", hardEscHandler, true);
      target.addEventListener("keyup", hardEscHandler, true);
    }

    add(window);
    add(document);
    add(document.documentElement);
    setTimeout(() => add(document.body), 500);

    console.log("[AI Voice Bridge] Hard ESC stop installed.");
  }

  function getLabel(el) {
    return (
      el?.getAttribute?.("aria-label") ||
      el?.getAttribute?.("title") ||
      el?.getAttribute?.("data-testid") ||
      el?.innerText ||
      el?.textContent ||
      ""
    ).trim().toLowerCase();
  }

  function initChatGPTConnector() {
    let lastUrl = location.href;
    let lastUserCount = 0;

    let pending = false;
    let pendingStartedAt = 0;
    let userCountAtArm = 0;
    let pendingPromptNorm = "";
    let fallbackUserNumber = 0;

    let candidateText = "";
    let candidateSince = 0;
    let settleTimer = null;
    let lastChatGPTMutationAt = 0;
    let latestChatGPTHasThoughtDivider = false;
    let suppressUntil = Date.now() + 2000;

    function getPromptBox() {
      return document.querySelector("#prompt-textarea");
    }

    function getPromptText() {
      const prompt = getPromptBox();
      if (!prompt) return "";
      return cleanText(prompt.innerText || prompt.textContent || prompt.value || "");
    }

    function getAllMessages() {
      return Array.from(document.querySelectorAll("[data-message-author-role]"));
    }

    function getUserMessages() {
      return Array.from(document.querySelectorAll('[data-message-author-role="user"]'));
    }

    function getUserCount() {
      return getUserMessages().length;
    }

    function isChatGPTBusy() {
      const buttons = Array.from(document.querySelectorAll("button"));

      for (const button of buttons) {
        const label = getLabel(button);

        if (
          label.includes("stop generating") ||
          label.includes("stop streaming") ||
          label.includes("stop response") ||
          label === "stop"
        ) {
          return true;
        }
      }

      const main = document.querySelector("main") || document.body;
      const mainText = normalize(main.innerText || main.textContent || "");

      return (
        mainText.includes("updating memory") ||
        mainText.endsWith("thinking") ||
        mainText.endsWith("analyzing image")
      );
    }

    function promptMatchesUser(userTextNorm) {
      if (!pendingPromptNorm || pendingPromptNorm.length < 2) {
        return false;
      }

      return (
        userTextNorm === pendingPromptNorm ||
        userTextNorm.includes(pendingPromptNorm) ||
        pendingPromptNorm.includes(userTextNorm)
      );
    }

    function findTargetUserIndex() {
      const messages = getAllMessages();
      let seenUsers = 0;
      let fallbackIndex = -1;
      let promptMatchIndex = -1;

      for (let i = 0; i < messages.length; i++) {
        const role = messages[i].getAttribute("data-message-author-role");

        if (role !== "user") continue;

        seenUsers += 1;

        if (seenUsers === fallbackUserNumber) {
          fallbackIndex = i;
        }

        const userText = cleanText(messages[i].innerText || messages[i].textContent || "");
        const userNorm = normalize(userText);

        if (promptMatchesUser(userNorm)) {
          promptMatchIndex = i;
        }
      }

      if (promptMatchIndex !== -1) {
        return promptMatchIndex;
      }

      if (!pendingPromptNorm && fallbackIndex !== -1) {
        return fallbackIndex;
      }

      return -1;
    }

    function cleanChatGPTAssistantText(text) {
      const dropExact = new Set([
        "copy",
        "copied",
        "retry",
        "regenerate",
        "regenerate response",
        "share",
        "edit",
        "like",
        "dislike",
        "listen",
        "read aloud",
        "stop",
        "send",
        "sources",
        "show thinking",
        "hide thinking",
        "show reasoning",
        "hide reasoning",
        "thinking"
      ]);

      function keepReadableLine(line) {
        const n = normalize(line);
        if (!n) return false;
        if (dropExact.has(n)) return false;
        if (/^thought for\b/.test(n)) return false;
        if (/^[.\s•·●○◌◍◦]+$/.test(n)) return false;
        if (n.startsWith("chatgpt can make mistakes")) return false;
        return true;
      }

      function cleanLines(lines) {
        return lines
          .map(line => line.trim())
          .filter(keepReadableLine)
          .join("\n")
          .replace(/\n{3,}/g, "\n\n")
          .trim();
      }

      const lines = cleanText(text).split("\n");
      const thoughtIndex = lines.findIndex(line => /^thought for\b/.test(normalize(line)));

      // For ChatGPT reasoning answers, treat anything above "Thought for ..." as
      // pre-answer status text. If a real answer exists below the divider, speak
      // only that final answer body. If not, wait instead of speaking early.
      if (thoughtIndex !== -1) {
        const postThoughtText = cleanLines(lines.slice(thoughtIndex + 1));
        return postThoughtText.length >= 8 ? postThoughtText : "";
      }

      return cleanLines(lines);
    }

    function looksLikeChatGPTPreAnswerStatus(text) {
      const t = normalize(text || "");
      const firstLine = t.split("\n")[0] || "";
      const firstChunk = t.slice(0, 900);
      const statusWords = /\b(verify|check|checking|look|looking|search|searching|sanity|pin|dig|compare|confirm|separate|pull|find|review|inspect|test|testing|current|official|policy|source|sources|docs?|language|keep it tight|syntax|diff|patch|patched|harden|hardened)\b/;
      const progressPhrases = /\b(first finding|quick finding|initial finding|checking whether|i(?:'|’)m going to|i\s+am going to|i(?:'|’)m checking|i\s+am checking|i(?:'|’)ll check|i\s+will check|let me check|i(?:'|’)ll compare|i\s+will compare|i(?:'|’)ll review|i\s+will review)\b/;

      return (
        progressPhrases.test(firstChunk) ||
        (/^i(?:'|’)ll\b/.test(firstLine) && statusWords.test(firstChunk)) ||
        (/^i\s+will\b/.test(firstLine) && statusWords.test(firstChunk)) ||
        (/^i(?:'|’)m\b/.test(firstLine) && statusWords.test(firstChunk)) ||
        (/^i\s+am\b/.test(firstLine) && statusWords.test(firstChunk)) ||
        (/^let me\b/.test(firstLine) && statusWords.test(firstChunk)) ||
        (/^(yep|yes|yeah|got it|understood|right|brother)[,\s—-]+/.test(firstLine) && statusWords.test(firstChunk) && /\bi(?:'|’)m|\bi\s+am|\bi(?:'|’)ll|\bi\s+will|\blet me\b/.test(firstChunk))
      );
    }

    function getAssistantContainerCandidates(assistantEl) {
      const candidates = [assistantEl];

      for (const selector of [
        '[data-testid="conversation-turn-assistant"]',
        '[data-testid^="conversation-turn-"]',
        'article'
      ]) {
        try {
          const match = assistantEl.closest(selector);
          if (match) candidates.push(match);
        } catch (_) {}
      }

      let parent = assistantEl.parentElement;
      for (let depth = 0; parent && depth < 4; depth++) {
        if (parent.matches("main, body, html")) break;

        const roles = Array.from(parent.querySelectorAll("[data-message-author-role]"));
        const containsOtherUser = roles.some(el =>
          el !== assistantEl && el.getAttribute("data-message-author-role") === "user"
        );

        if (!containsOtherUser) {
          candidates.push(parent);
        }

        parent = parent.parentElement;
      }

      return uniqueElements(candidates);
    }

    function getAssistantTextAfterTargetUser() {
      const messages = getAllMessages();
      const targetUserIndex = findTargetUserIndex();

      if (targetUserIndex === -1) {
        return "";
      }

      const assistantEls = [];

      for (let i = targetUserIndex + 1; i < messages.length; i++) {
        const role = messages[i].getAttribute("data-message-author-role");

        if (role === "user") {
          break;
        }

        if (role === "assistant") {
          assistantEls.push(messages[i]);
        }
      }

      if (!assistantEls.length) {
        return "";
      }

      // ChatGPT can render an opener/status chunk, a visible "Thought for ..."
      // divider, then the real final answer in a nearby sibling/container. Check
      // all assistant chunks after the target user, then choose the best full text.
      let bestText = "";
      let bestHadThoughtDivider = false;
      let bestPostThoughtText = "";
      let sawThoughtDivider = false;
      const containers = uniqueElements(assistantEls.flatMap(el => getAssistantContainerCandidates(el)));

      for (const el of containers) {
        const rawText = el.innerText || el.textContent || "";
        const hadThoughtDivider = /(^|\n)\s*Thought for\b/i.test(rawText);
        if (hadThoughtDivider) {
          sawThoughtDivider = true;
        }

        const text = cleanChatGPTAssistantText(rawText);
        if (!text || text.length >= 30000) {
          continue;
        }

        // If a container includes the Thought divider and cleaned text exists,
        // that cleaned text is the final body below the divider. Prefer it.
        if (hadThoughtDivider && text.length > bestPostThoughtText.length) {
          bestPostThoughtText = text;
        }

        if (text.length > bestText.length) {
          bestText = text;
          bestHadThoughtDivider = hadThoughtDivider;
        }
      }

      if (bestPostThoughtText) {
        latestChatGPTHasThoughtDivider = true;
        return bestPostThoughtText;
      }

      // If the Thought divider is visible but there is no real post-divider
      // answer yet, keep waiting. This prevents the bridge from speaking the
      // opener/status line during long reasoning turns.
      if (sawThoughtDivider) {
        latestChatGPTHasThoughtDivider = false;
        return "";
      }

      latestChatGPTHasThoughtDivider = bestHadThoughtDivider;
      return bestText;
    }

    function primePage() {
      pending = false;
      pendingStartedAt = 0;
      pendingPromptNorm = "";
      fallbackUserNumber = 0;
      candidateText = "";
      candidateSince = 0;
      lastChatGPTMutationAt = 0;
      latestChatGPTHasThoughtDivider = false;
      clearTimeout(settleTimer);

      lastUserCount = getUserCount();

      console.log("[AI Voice Bridge] ChatGPT primed. User count:", lastUserCount);
    }

    function armForSend(source = "send") {
      if (Date.now() < suppressUntil) return;

      const promptText = getPromptText();

      pending = true;
      pendingStartedAt = Date.now();
      userCountAtArm = getUserCount();
      fallbackUserNumber = userCountAtArm + 1;
      pendingPromptNorm = normalize(promptText);
      candidateText = "";
      candidateSince = 0;
      lastChatGPTMutationAt = Date.now();
      latestChatGPTHasThoughtDivider = false;
      clearTimeout(settleTimer);

      console.log("[AI Voice Bridge] ChatGPT armed:", source, "prompt:", pendingPromptNorm.slice(0, 80));
      scheduleCheck(250);
    }

    function armForExistingUser(userCount) {
      if (Date.now() < suppressUntil) return;

      const users = getUserMessages();
      const latestUser = users[users.length - 1];
      const latestUserText = cleanText(latestUser?.innerText || latestUser?.textContent || "");

      pending = true;
      pendingStartedAt = Date.now();
      userCountAtArm = Math.max(0, userCount - 1);
      fallbackUserNumber = userCount;
      pendingPromptNorm = normalize(latestUserText);
      candidateText = "";
      candidateSince = 0;
      lastChatGPTMutationAt = Date.now();
      latestChatGPTHasThoughtDivider = false;
      clearTimeout(settleTimer);

      console.log("[AI Voice Bridge] ChatGPT armed from new user bubble:", pendingPromptNorm.slice(0, 80));
      scheduleCheck(250);
    }

    function scheduleCheck(delay = 250) {
      clearTimeout(settleTimer);
      settleTimer = setTimeout(maybeSpeakAfterSettled, delay);
    }

    function maybeSpeakAfterSettled() {
      if (!pending) return;

      if (Date.now() - pendingStartedAt > 600000) {
        console.log("[AI Voice Bridge] ChatGPT timed out waiting for assistant response.");
        primePage();
        return;
      }

      if (getUserCount() <= userCountAtArm) {
        scheduleCheck(250);
        return;
      }

      if (isChatGPTBusy()) {
        scheduleCheck(350);
        return;
      }

      const latestText = getAssistantTextAfterTargetUser();

      // Reasoning-model guard: ChatGPT can show an opener, then keep updating
      // the visible "Thought for ..." timer before the final answer exists.
      // Do not speak until the assistant turn has stopped mutating briefly.
      const looksLikePreAnswerStatus = looksLikeChatGPTPreAnswerStatus(latestText);
      const hasFinalThoughtBody = Boolean(latestText && latestChatGPTHasThoughtDivider);
      const quietWaitMs = hasFinalThoughtBody
        ? 800
        : looksLikePreAnswerStatus
          ? 4500
          : 500;

      if (lastChatGPTMutationAt && Date.now() - lastChatGPTMutationAt < quietWaitMs) {
        scheduleCheck(250);
        return;
      }

      if (!latestText || isJunkText(latestText)) {
        scheduleCheck(250);
        return;
      }

      if (looksLikePreAnswerStatus && !hasFinalThoughtBody) {
        scheduleCheck(500);
        return;
      }

      if (latestText !== candidateText) {
        candidateText = latestText;
        candidateSince = Date.now();
        scheduleCheck(250);
        return;
      }

      const finalSettleMs = hasFinalThoughtBody
        ? 700
        : looksLikePreAnswerStatus
          ? 1200
          : 500;

      if (Date.now() - candidateSince < finalSettleMs) {
        scheduleCheck(250);
        return;
      }

      speakText(latestText);

      pending = false;
      pendingStartedAt = 0;
      pendingPromptNorm = "";
      fallbackUserNumber = 0;
      candidateText = "";
      candidateSince = 0;
    }

    function isPromptBox(el) {
      const prompt = getPromptBox();

      return Boolean(
        prompt &&
        (
          el === prompt ||
          prompt.contains(el) ||
          el?.closest?.("#prompt-textarea") ||
          document.activeElement === prompt ||
          prompt.contains(document.activeElement)
        )
      );
    }

    function isSendButtonClick(event) {
      const button = event.target.closest("button,[role='button']");
      if (!button) return false;

      const prompt = getPromptBox();
      const form = prompt?.closest("form");

      if (form && !form.contains(button)) return false;

      const label = getLabel(button);

      return (
        label === "send" ||
        label === "send prompt" ||
        label === "send message" ||
        label === "send-button" ||
        label.includes("send")
      );
    }

    document.addEventListener("keydown", (event) => {
      if (event.key === "Enter" && !event.shiftKey && isPromptBox(event.target)) {
        armForSend("enter");
      }
    }, true);

    document.addEventListener("click", (event) => {
      if (isSendButtonClick(event)) {
        armForSend("send button");
      }
    }, true);

    const observer = new MutationObserver(() => {
      if (pending) {
        lastChatGPTMutationAt = Date.now();
      }

      if (location.href !== lastUrl) {
        lastUrl = location.href;

        if (!pending) {
          suppressUntil = Date.now() + 2500;
          stopVoice("navigation");
          setTimeout(primePage, 500);
          setTimeout(primePage, 1500);
          return;
        }

        console.log("[AI Voice Bridge] ChatGPT URL changed while pending. Still waiting.");
      }

      const userCount = getUserCount();

      if (userCount > lastUserCount) {
        lastUserCount = userCount;

        if (!pending) {
          armForExistingUser(userCount);
        }
      } else if (userCount < lastUserCount) {
        primePage();
        return;
      }

      maybeSpeakAfterSettled();
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true
    });

    setTimeout(primePage, 1500);

    console.log("[AI Voice Bridge] ChatGPT connector loaded. Port 5055.");
  }

  function initGenericConnector() {
    let lastUrl = location.href;
    let pending = false;
    let pendingStartedAt = 0;
    let candidateText = "";
    let candidateSince = 0;
    let settleTimer = null;
    let suppressUntil = Date.now() + 2000;
    let ignoreNavigationStopUntil = 0;
    let baseline = new Set();
    let lastSpoken = "";

    function getPromptBox() {
      for (const selector of SITE.inputSelectors || []) {
        const matches = querySelectorAllDeep(selector).filter(isVisible);
        const active = matches.find(el => el === document.activeElement || el.contains(document.activeElement));
        if (active) return active;
        if (matches.length) return matches[matches.length - 1];
      }
      return null;
    }

    function isPromptBox(el) {
      const prompt = getPromptBox();
      if (!prompt) return false;

      return Boolean(
        el === prompt ||
        prompt.contains(el) ||
        document.activeElement === prompt ||
        prompt.contains(document.activeElement) ||
        (SITE.inputSelectors || []).some(selector => el?.closest?.(selector))
      );
    }

    function isSendButtonClick(event) {
      const button = event.target.closest("button,[role='button']");
      if (!button || !isVisible(button)) return false;

      const label = getLabel(button);
      const testid = (button.getAttribute("data-testid") || "").toLowerCase();
      const aria = (button.getAttribute("aria-label") || "").toLowerCase();

      if (SITE.id === "deepseek") {
        const prompt = getPromptBox();
        const buttonRect = button.getBoundingClientRect();
        const promptRect = prompt?.getBoundingClientRect?.();
        const nearPrompt = Boolean(promptRect &&
          buttonRect.left >= promptRect.left - 80 &&
          buttonRect.right <= promptRect.right + 120 &&
          buttonRect.top >= promptRect.top - 120 &&
          buttonRect.bottom <= promptRect.bottom + 120
        );
        const iconButton = Boolean(button.querySelector("svg") || button.innerHTML.includes("svg"));
        const disabled = button.disabled || button.getAttribute("aria-disabled") === "true";
        if (!disabled && nearPrompt && iconButton) {
          return true;
        }
      }

      return (
        label === "send" ||
        label.includes("send") ||
        testid.includes("send") ||
        aria.includes("send") ||
        aria.includes("submit")
      );
    }

    function isBadContainer(el) {
      return Boolean(el.closest("nav, header, footer, aside, form, textarea, input, button, [role='button'], [role='navigation'], [class*='sidebar'], [class*='side-bar']"));
    }

    function getCandidateElements() {
      const selectors = SITE.assistantSelectors || [];
      let elements = [];

      const searchRoot = document.querySelector("main") || document.body || document;
      for (const selector of selectors) {
        try {
          elements = elements.concat(querySelectorAllDeep(selector, searchRoot));
        } catch (_) {}
      }

      elements = uniqueElements(elements)
        .filter(isVisible)
        .filter(el => !isBadContainer(el));

      // Prefer specific lower-level message/markdown elements over giant wrappers when both exist.
      elements = elements.filter(el => {
        const text = cleanCandidateText(el.innerText || el.textContent || "");
        if (text.length < 2) return false;
        if (text.length > 20000) return false;

        const childWithSameText = elements.some(other => {
          if (other === el) return false;
          if (!el.contains(other)) return false;
          const childText = cleanCandidateText(other.innerText || other.textContent || "");
          return childText.length > 80 && text.includes(childText) && childText.length / text.length > 0.75;
        });

        return !childWithSameText;
      });

      return elements;
    }


    function getDeepSeekTextCandidates() {
      const rawItems = getCandidateElements()
        .map(el => {
          const rect = el.getBoundingClientRect();
          const raw = cleanCandidateText(el.innerText || el.textContent || "");
          const text = cleanDeepSeekAssistantText(raw);
          const reasoning = isDeepSeekReasoningText(raw);
          return { el, raw, text, reasoning, bottom: rect.bottom, top: rect.top };
        })
        .filter(item => item.raw)
        .sort((a, b) => {
          if (a.bottom !== b.bottom) return a.bottom - b.bottom;
          return a.raw.length - b.raw.length;
        });

      const reasoningBottom = rawItems
        .filter(item => item.reasoning)
        .reduce((max, item) => Math.max(max, item.bottom), -1);

      return rawItems
        .filter(item => item.text && !isJunkText(item.text))
        .filter(item => !item.reasoning)
        .filter(item => reasoningBottom < 0 || item.top >= reasoningBottom - 8)
        .sort((a, b) => {
          if (a.bottom !== b.bottom) return a.bottom - b.bottom;
          return a.text.length - b.text.length;
        });
    }

    function getTextCandidates() {
      if (SITE.id === "deepseek") {
        return getDeepSeekTextCandidates();
      }

      return getCandidateElements()
        .map(el => {
          const rect = el.getBoundingClientRect();
          let text = cleanCandidateText(el.innerText || el.textContent || "");

          if (SITE.id === "deepseek") text = cleanDeepSeekAssistantText(text);
          return { el, text, bottom: rect.bottom, top: rect.top };
        })
        .filter(item => item.text && !isJunkText(item.text))
        .sort((a, b) => {
          if (a.bottom !== b.bottom) return a.bottom - b.bottom;
          return a.text.length - b.text.length;
        });
    }

    function captureBaseline() {
      baseline = new Set(getTextCandidates().map(item => fingerprint(item.text)));
      console.log(`[AI Voice Bridge] ${SITE.label} baseline captured:`, baseline.size);
    }

    function combineDeepSeekTextItems(items) {
      const pieces = [];

      for (const item of items) {
        const text = SITE.id === "deepseek"

          ? cleanDeepSeekAssistantText(item?.text || "")

          : cleanCandidateText(item?.text || "");
        if (!text || isJunkText(text)) continue;

        // If a larger wrapper already contains this paragraph, skip the smaller duplicate.
        if (pieces.some(piece => piece.includes(text))) continue;

        // If this text is the larger wrapper, remove smaller pieces it contains.
        for (let i = pieces.length - 1; i >= 0; i--) {
          if (text.includes(pieces[i])) {
            pieces.splice(i, 1);
          }
        }

        pieces.push(text);
      }

      return cleanCandidateText(pieces.join("\n\n"));
    }

    function findLatestAssistantText() {
      const candidates = getTextCandidates();
      if (!candidates.length) return "";

      const newCandidates = candidates.filter(item => !baseline.has(fingerprint(item.text)));

      // Gemini may expose a whole accessibility transcript wrapper ("You said / Gemini said").
      // Prefer specific model response nodes and only speak a new Gemini answer.
      if (SITE.id === "gemini") {
        const geminiCandidates = getGeminiAssistantTextCandidates();
        const newGeminiCandidates = geminiCandidates.filter(item => !baseline.has(fingerprint(item.text)));
        const latestGemini = newGeminiCandidates[newGeminiCandidates.length - 1];
        return latestGemini?.text || "";
      }

      // DeepSeek exposes expanded DeepThink reasoning as normal visible DOM.
      // For DeepSeek, getTextCandidates() already filters candidates to answer
      // text below the reasoning block, so do not recombine reasoning/answer pieces.
      if (SITE.id === "deepseek") {
        const pool = newCandidates.length ? newCandidates : candidates;
        const latest = pool[pool.length - 1];
        return latest?.text || "";
      }

      const pool = newCandidates.length ? newCandidates : candidates;
      const latest = pool[pool.length - 1];
      return latest?.text || "";
    }

    function isSiteBusy() {
      const buttons = Array.from(document.querySelectorAll("button,[role='button']"));

      for (const button of buttons) {
        const label = getLabel(button);
        if (
          label.includes("stop generating") ||
          label.includes("stop response") ||
          label.includes("stop streaming") ||
          label.includes("cancel response") ||
          label.includes("interrupt") ||
          label === "stop"
        ) {
          return true;
        }
      }

      const bodyText = normalize((document.querySelector("main") || document.body).innerText || "");
      return (
        bodyText.endsWith("thinking") ||
        bodyText.endsWith("generating") ||
        bodyText.endsWith("loading") ||
        bodyText.includes(`${SITE.id} is responding`)
      );
    }

    function primePage() {
      pending = false;
      pendingStartedAt = 0;
      candidateText = "";
      candidateSince = 0;
      clearTimeout(settleTimer);
      captureBaseline();
    }

    function armForSend(source = "send") {
      if (Date.now() < suppressUntil) return;

      pending = true;
      pendingStartedAt = Date.now();
      candidateText = "";
      candidateSince = 0;
      captureBaseline();
      clearTimeout(settleTimer);

      console.log(`[AI Voice Bridge] ${SITE.label} armed:`, source);
      scheduleCheck(350);
    }

    function scheduleCheck(delay = 350) {
      clearTimeout(settleTimer);
      settleTimer = setTimeout(maybeSpeakAfterSettled, delay);
    }

    function maybeSpeakAfterSettled() {
      if (!pending) return;

      if (Date.now() - pendingStartedAt > 600000) {
        console.log(`[AI Voice Bridge] ${SITE.label} timed out waiting for response.`);
        primePage();
        return;
      }

      if (isSiteBusy()) {
        scheduleCheck(450);
        return;
      }

      const latestText = findLatestAssistantText();

      if (!latestText || isJunkText(latestText)) {
        scheduleCheck(350);
        return;
      }

      if (latestText !== candidateText) {
        candidateText = latestText;
        candidateSince = Date.now();
        scheduleCheck(350);
        return;
      }

      if (Date.now() - candidateSince < 1600) {
        scheduleCheck(350);
        return;
      }

      const fp = fingerprint(latestText);
      if (fp && fp !== lastSpoken) {
        speakText(latestText);
        lastSpoken = fp;
        ignoreNavigationStopUntil = Date.now() + 45000;
      }

      pending = false;
      pendingStartedAt = 0;
      candidateText = "";
      candidateSince = 0;
      setTimeout(captureBaseline, 500);
    }

    document.addEventListener("keydown", (event) => {
      if (event.key === "Enter" && !event.shiftKey && isPromptBox(event.target)) {
        armForSend("enter");
      }
    }, true);

    document.addEventListener("click", (event) => {
      if (isSendButtonClick(event)) {
        armForSend("send button");
      }
    }, true);

    const observer = new MutationObserver(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;

        if (!pending) {
          suppressUntil = Date.now() + 2500;
          if (Date.now() > ignoreNavigationStopUntil) {
            stopVoice("navigation");
          } else {
            console.log(`[AI Voice Bridge] ${SITE.label} URL changed after response; not stopping active speech.`);
          }
          setTimeout(primePage, 800);
          setTimeout(primePage, 1800);
          return;
        }
      }

      maybeSpeakAfterSettled();
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true
    });

    setTimeout(primePage, 1500);

    console.log(`[AI Voice Bridge] ${SITE.label} connector loaded. Mini-universal . Port 5055.`);
  }


  // ---------------------------------------------------------------------------
  // AI Voice Bridge Dictation / Voice Agent (v3.0 button fix)
  // Records microphone locally in Chrome, converts to real 16-bit PCM WAV,
  // sends only the WAV to the local server on 127.0.0.1, then inserts returned
  // text into the active AI prompt box. User reviews and presses Enter manually.
  // ---------------------------------------------------------------------------
  const RITS_DICTATION_HOTKEY = "Cmd+Shift+V";
  const RITS_DICTATION_TARGET_RATE = 16000;
  const RITS_DICTATION_MAX_SECONDS = 300;
  const RITS_DICTATION_STOP_TAIL_MS = 350;

  let ritsDictation = {
    recording: false,
    stopping: false,
    stream: null,
    audioContext: null,
    source: null,
    processor: null,
    silentGain: null,
    chunks: [],
    startedAt: 0,
    autoStopTimer: null,
    indicator: null
  };

  let ritsDictationButton = null;
  let ritsFloatingControl = null;
  let ritsReadButton = null;
  let ritsStopButton = null;
  let ritsDragHandle = null;
  let ritsLastReadableText = "";
  let ritsManualReadStopTimer = null;

  const RITS_FLOATING_CONTROL_KEY = `rits_voice_bridge_floating_button_${SITE.id}_${location.hostname}`;

  function estimateSpeechDurationMs(text) {
    const length = cleanText(text || "").length;
    if (!length) return 2500;
    return Math.max(3500, Math.min(90000, Math.round(length * 72)));
  }

  function setLastReadableText(text) {
    const cleaned = cleanText(text || "");
    if (!cleaned || isJunkText(cleaned)) return;
    ritsLastReadableText = cleaned;
    if (ritsReadButton?.dataset?.reading !== "true") {
      updateReadButtonState("ready");
    }
  }

  function updateReadButtonState(state = "ready", labelOverride = "") {
    if (!ritsReadButton) return;
    const isNotice = state === "notice";
    const isReading = state === "reading";
    ritsReadButton.textContent = labelOverride || (isNotice ? "!" : "↻");
    ritsReadButton.title = isReading
      ? "Repeat latest response from the beginning"
      : "Repeat latest response aloud";
    ritsReadButton.setAttribute("aria-label", "Repeat latest response aloud");
    ritsReadButton.style.background = isNotice ? "#92400e" : (isReading ? "#15803d" : "#16a34a");
    ritsReadButton.style.color = "#fff";
    ritsReadButton.style.opacity = "1";
  }

  function markManualReadStopped() {
    clearTimeout(ritsManualReadStopTimer);
    if (ritsReadButton?.dataset) ritsReadButton.dataset.reading = "false";
    updateReadButtonState("ready");
  }

  async function stopManualReadback(reason = "strip stop") {
    await stopVoice(reason);
    markManualReadStopped();
  }

  function getManualReadCandidates() {
    const searchRoot = document.querySelector("main") || document.body || document;
    let elements = [];

    if (SITE.id === "chatgpt") {
      elements = Array.from(searchRoot.querySelectorAll('[data-message-author-role="assistant"]'));
    } else {
      for (const selector of SITE.assistantSelectors || []) {
        try {
          elements = elements.concat(querySelectorAllDeep(selector, searchRoot));
        } catch (_) {}
      }
    }

    return uniqueElements(elements)
      .filter(el => el && isVisible(el))
      .filter(el => !el.closest?.("nav, header, footer, aside, form, textarea, input, button, [role='button'], [role='navigation']"))
      .map(el => {
        const rect = el.getBoundingClientRect?.();
        let text = cleanCandidateText(el.innerText || el.textContent || "");

        if (SITE.id === "deepseek") text = cleanDeepSeekAssistantText(text);
        return { el, text, bottom: rect?.bottom || 0, top: rect?.top || 0 };
      })
      .filter(item => item.text && !isJunkText(item.text) && item.text.length < 30000)
      .sort((a, b) => {
        if (a.bottom !== b.bottom) return a.bottom - b.bottom;
        return a.text.length - b.text.length;
      });
  }

  function getLatestManualReadText() {
    if (SITE.id === "gemini") {
      const geminiCandidates = getGeminiAssistantTextCandidates();
      const latestGemini = geminiCandidates[geminiCandidates.length - 1];
      if (latestGemini?.text && !isJunkText(latestGemini.text)) {
        return latestGemini.text;
      }
    }

    if (ritsLastReadableText && !isJunkText(ritsLastReadableText)) {
      return ritsLastReadableText;
    }

    const candidates = getManualReadCandidates();
    if (!candidates.length) return "";

    if (SITE.id === "deepseek") {
      return candidates[candidates.length - 1]?.text || "";
    }

    return candidates[candidates.length - 1]?.text || "";
  }

  function flashReadButton(label, ms = 1100) {
    updateReadButtonState("notice", label);
    setTimeout(() => updateReadButtonState("ready"), ms);
  }

  async function readLastResponseAloud() {
    const text = getLatestManualReadText();
    if (!text || isJunkText(text)) {
      flashReadButton("!");
      return;
    }

    clearTimeout(ritsManualReadStopTimer);
    if (ritsReadButton?.dataset) ritsReadButton.dataset.reading = "true";
    updateReadButtonState("reading");
    await speakText(text);

    ritsManualReadStopTimer = setTimeout(() => {
      markManualReadStopped();
    }, estimateSpeechDurationMs(text));
  }



  function isGeminiPromptHost(el) {
    if (SITE.id !== "gemini" || !el) return false;
    const tag = String(el.tagName || "").toLowerCase();
    const attrs = [
      el.id,
      el.getAttribute?.("aria-label"),
      el.getAttribute?.("placeholder"),
      el.getAttribute?.("data-placeholder"),
      el.getAttribute?.("class")
    ].filter(Boolean).join(" ").toLowerCase();
    const rect = el.getBoundingClientRect?.();
    const bottomHalf = rect && rect.bottom > (window.innerHeight || 900) * 0.50;
    const composerSized = rect && rect.width >= 180 && rect.height >= 22 && rect.height <= 180;

    return Boolean(
      tag === "rich-textarea" ||
      tag === "bard-text-input" ||
      (/ask gemini|enter a prompt|prompt|input|textarea|query/.test(attrs) && bottomHalf && composerSized)
    );
  }

  function findGeminiComposerShell() {
    if (SITE.id !== "gemini") return null;

    const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 900;
    const all = querySelectorAllDeep("*");
    const anchors = [];

    for (const el of all) {
      if (!el || !isVisible(el)) continue;
      const rect = el.getBoundingClientRect?.();
      if (!rect || rect.bottom < viewportHeight * 0.50) continue;

      const labelText = [
        el.getAttribute?.("aria-label"),
        el.getAttribute?.("placeholder"),
        el.getAttribute?.("data-placeholder"),
        cleanText(el.innerText || el.textContent || "")
      ].filter(Boolean).join(" ").toLowerCase();

      if (/ask gemini|enter a prompt/.test(labelText)) {
        anchors.push(el);
      }
    }

    let best = null;
    let bestScore = -Infinity;

    function consider(candidate, anchorRect) {
      const rect = candidate?.getBoundingClientRect?.();
      if (!rect) return;
      if (rect.bottom < viewportHeight * 0.50) return;
      if (rect.width < 320 || rect.width > Math.min(980, window.innerWidth * 0.86)) return;
      if (rect.height < 36 || rect.height > 140) return;

      const containsAnchor = Boolean(anchorRect &&
        anchorRect.left >= rect.left - 4 && anchorRect.right <= rect.right + 4 &&
        anchorRect.top >= rect.top - 4 && anchorRect.bottom <= rect.bottom + 4
      );
      if (!containsAnchor) return;

      const bottomScore = Math.max(0, 500 - Math.abs((viewportHeight - 42) - rect.bottom));
      const widthScore = Math.min(rect.width, 760);
      const centerPenalty = Math.abs((rect.left + rect.width / 2) - (window.innerWidth / 2)) * 0.15;
      const score = widthScore + bottomScore - centerPenalty - Math.max(0, rect.height - 80) * 2;

      if (score > bestScore) {
        bestScore = score;
        best = { el: candidate, rect };
      }
    }

    for (const anchor of anchors) {
      const anchorRect = anchor.getBoundingClientRect?.();
      let node = anchor;
      let depth = 0;
      while (node && node !== document.body && node !== document.documentElement && depth < 10) {
        consider(node, anchorRect);
        node = node.parentElement || node.getRootNode?.()?.host || null;
        depth += 1;
      }
    }

    if (best) return best;

    // Last fallback: use the visible bottom prompt-like control row if labels are not exposed.
    const bottomButtons = querySelectorAllDeep("button,[role='button'],svg,span,div")
      .filter(el => {
        if (!isVisible(el)) return false;
        const rect = el.getBoundingClientRect?.();
        if (!rect || rect.bottom < viewportHeight * 0.72) return false;
        const text = [el.getAttribute?.("aria-label"), el.getAttribute?.("title"), cleanText(el.innerText || el.textContent || "")]
          .filter(Boolean).join(" ").toLowerCase();
        return /ask gemini|flash|microphone|mic|submit|send/.test(text);
      });

    for (const el of bottomButtons) {
      const rect = el.getBoundingClientRect?.();
      let node = el;
      let depth = 0;
      while (node && node !== document.body && node !== document.documentElement && depth < 8) {
        consider(node, rect);
        node = node.parentElement || node.getRootNode?.()?.host || null;
        depth += 1;
      }
    }

    return best;
  }

  function dispatchGeminiClickAt(x, y) {
    const target = document.elementFromPoint(x, y);
    if (!target) return null;
    const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y };
    try { target.dispatchEvent(new PointerEvent("pointerdown", opts)); } catch (_) {}
    try { target.dispatchEvent(new MouseEvent("mousedown", opts)); } catch (_) {}
    try { target.focus?.(); } catch (_) {}
    try { target.dispatchEvent(new PointerEvent("pointerup", opts)); } catch (_) {}
    try { target.dispatchEvent(new MouseEvent("mouseup", opts)); } catch (_) {}
    try { target.dispatchEvent(new MouseEvent("click", opts)); } catch (_) {}
    try { target.click?.(); } catch (_) {}
    return target;
  }

  function focusGeminiComposerShell(shellInfoOrEl) {
    const shell = shellInfoOrEl?.el || shellInfoOrEl;
    const rect = shellInfoOrEl?.rect || shell?.getBoundingClientRect?.();
    if (!shell || !rect) return null;

    // Click in the text-entry lane, not the model menu or microphone on the right.
    const x = Math.round(rect.left + Math.min(Math.max(rect.width * 0.20, 90), rect.width - 160));
    const y = Math.round(rect.top + rect.height / 2);
    const clicked = dispatchGeminiClickAt(x, y);
    try { shell.focus?.(); } catch (_) {}
    return getDeepActiveElement() || clicked || shell;
  }

  function setNativeValue(el, value) {
    const tag = String(el?.tagName || "").toLowerCase();
    const proto = tag === "textarea"
      ? window.HTMLTextAreaElement?.prototype
      : tag === "input"
        ? window.HTMLInputElement?.prototype
        : null;
    const setter = proto ? Object.getOwnPropertyDescriptor(proto, "value")?.set : null;
    if (setter) {
      setter.call(el, value);
    } else {
      el.value = value;
    }
  }

  function dispatchTextInputEvents(el, data) {
    try { el.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data })); } catch (_) {}
    try { el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data })); } catch (_) {}
    try { el.dispatchEvent(new Event("change", { bubbles: true })); } catch (_) {}
    try { el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: " ", code: "Space" })); } catch (_) {}
  }

  function tryGeminiInsertText(input, textToInsert, rawText) {
    if (SITE.id !== "gemini" || !input) return false;

    try { input.scrollIntoView?.({ block: "center", inline: "nearest" }); } catch (_) {}
    try { input.click?.(); } catch (_) {}
    try { input.focus?.(); } catch (_) {}

    const possibleTargets = [];
    const shellInfo = findGeminiComposerShell();
    const shellFocus = shellInfo ? focusGeminiComposerShell(shellInfo) : null;
    if (shellFocus) possibleTargets.push(shellFocus);
    const active = getDeepActiveElement();
    if (active) possibleTargets.push(active);
    possibleTargets.push(input);
    if (shellInfo?.el) possibleTargets.push(shellInfo.el);

    if (input.shadowRoot) {
      possibleTargets.push(...querySelectorAllDeep("textarea,input,[contenteditable],[role='textbox']", input.shadowRoot));
    }

    possibleTargets.push(...querySelectorAllDeep("rich-textarea textarea, rich-textarea input, rich-textarea [contenteditable], rich-textarea [role='textbox']"));
    possibleTargets.push(...querySelectorAllDeep("textarea,input,[contenteditable][aria-label*='prompt' i],[role='textbox'][aria-label*='prompt' i]"));

    for (const target of uniqueElements(possibleTargets)) {
      if (!target || !isVisible(target)) continue;
      const tag = String(target.tagName || "").toLowerCase();
      const editable = target.isContentEditable || target.getAttribute?.("contenteditable") !== null || target.getAttribute?.("role") === "textbox";

      if (tag === "textarea" || tag === "input") {
        const existing = target.value || "";
        const spacer = existing && !/\s$/.test(existing) ? " " : "";
        setNativeValue(target, existing + spacer + textToInsert);
        try { target.setSelectionRange(target.value.length, target.value.length); } catch (_) {}
        dispatchTextInputEvents(target, rawText);
        return true;
      }

      if (editable || isGeminiPromptHost(target)) {
        try { target.click?.(); } catch (_) {}
        try { target.focus?.(); } catch (_) {}
        try {
          const selection = window.getSelection();
          if (selection && target.isContentEditable) {
            const range = document.createRange();
            range.selectNodeContents(target);
            range.collapse(false);
            selection.removeAllRanges();
            selection.addRange(range);
          }
        } catch (_) {}

        const inserted = document.execCommand("insertText", false, textToInsert);
        dispatchTextInputEvents(target, rawText);
        if (inserted) return true;
      }
    }

    // Final Gemini fallback: click the visible prompt/composer host and try a page-level insert.
    try { input.click?.(); } catch (_) {}
    try { input.focus?.(); } catch (_) {}
    try {
      const inserted = document.execCommand("insertText", false, textToInsert);
      dispatchTextInputEvents(input, rawText);
      return Boolean(inserted);
    } catch (_) {
      return false;
    }
  }

  function findPromptInput() {
    // Mac/ChatGPT safety fix: only insert dictation into the actual AI composer.
    // ChatGPT pages can include other editable regions (writing blocks/artifacts),
    // so document.activeElement by itself is not safe.
    if (SITE.id === "chatgpt") {
      const exact = document.querySelector("#prompt-textarea");
      if (exact && isVisible(exact)) return exact;
      return null;
    }

    const selectors = [];

    selectors.push(
      ...(SITE.inputSelectors || []),
      "form textarea",
      "form [contenteditable='true']",
      "form [role='textbox']",
      "textarea[placeholder*='Message' i]",
      "textarea[placeholder*='Ask' i]",
      "[contenteditable='true'][aria-label*='message' i]",
      "[role='textbox'][aria-label*='message' i]",
      "textarea",
      "[contenteditable='true']",
      "[role='textbox']"
    );

    const seen = new Set();
    const candidates = [];

    for (const selector of selectors) {
      if (!selector || seen.has(selector)) continue;
      seen.add(selector);
      try {
        candidates.push(...querySelectorAllDeep(selector));
      } catch (_) {}
    }

    function isEditableCandidate(el) {
      if (!el || !isVisible(el)) return false;
      const blockedComposerAncestor = SITE.id === "gemini"
        ? "button,[role='button'],nav,header,aside"
        : "button,[role='button'],nav,header,footer,aside";
      if (el.closest(blockedComposerAncestor)) return false;

      const editable =
        el.tagName === "TEXTAREA" ||
        el.tagName === "INPUT" ||
        el.isContentEditable ||
        el.getAttribute("contenteditable") !== null ||
        el.getAttribute("role") === "textbox";
      const geminiHost = isGeminiPromptHost(el);

      if (!editable && !geminiHost) return false;

      const rect = el.getBoundingClientRect?.();
      if (!rect || rect.width < 120 || rect.height < 18) return false;

      const inForm = Boolean(el.closest("form"));
      const badContainer = el.closest(
        "article," +
        "[data-message-author-role]," +
        "[data-testid*='conversation-turn']," +
        "[data-testid*='artifact']," +
        "[data-testid*='canvas']," +
        "[class*='markdown']," +
        "[class*='prose']," +
        "[class*='message-content']," +
        "[class*='sidebar']," +
        "[class*='side-bar']"
      );
      if (badContainer && !inForm) return false;

      return true;
    }

    function scorePromptCandidate(el) {
      const rect = el.getBoundingClientRect?.() || { top: 0, bottom: 0, width: 0, height: 0 };
      const attrText = [
        el.id,
        el.getAttribute("placeholder"),
        el.getAttribute("aria-label"),
        el.getAttribute("data-placeholder"),
        el.getAttribute("name")
      ].filter(Boolean).join(" ").toLowerCase();

      let score = 0;
      if (el.closest("form")) score += 250;
      if (/message|prompt|ask|chat|send/.test(attrText)) score += 150;
      if (el.tagName === "TEXTAREA") score += 60;
      if (el.getAttribute("role") === "textbox") score += 40;
      if (el.isContentEditable) score += 30;
      if (isGeminiPromptHost(el)) score += 280;

      const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 1000;
      if (rect.bottom > viewportHeight * 0.55) score += 80;
      if (rect.bottom > viewportHeight * 0.75) score += 80;
      if (rect.width > 400) score += 40;
      if (rect.width > 700) score += 40;

      const active = document.activeElement;
      if (active && (el === active || el.contains(active))) score += 120;

      return score;
    }

    const deepActive = getDeepActiveElement();
    if (deepActive) candidates.push(deepActive);

    const visible = uniqueElements(candidates).filter(isEditableCandidate);
    if (!visible.length) {
      const geminiShell = findGeminiComposerShell();
      if (geminiShell?.el) return geminiShell.el;
      return null;
    }

    visible.sort((a, b) => scorePromptCandidate(b) - scorePromptCandidate(a));
    return visible[0] || null;
  }

  function insertTextIntoPrompt(text) {
    text = cleanText(text || "");
    if (!text) return false;

    const input = findPromptInput();
    if (!input) {
      alert("RITS Dictation: could not find the AI prompt box on this page.");
      return false;
    }

    input.focus();

    const textToInsert = text.endsWith(" ") ? text : text + " ";

    if (SITE.id === "gemini" && tryGeminiInsertText(input, textToInsert, text)) {
      return true;
    }

    if (input.tagName === "TEXTAREA" || input.tagName === "INPUT") {
      const start = typeof input.selectionStart === "number" ? input.selectionStart : input.value.length;
      const end = typeof input.selectionEnd === "number" ? input.selectionEnd : input.value.length;
      const before = input.value.slice(0, start);
      const after = input.value.slice(end);
      const spacer = before && !/\s$/.test(before) ? " " : "";
      input.value = before + spacer + textToInsert + after;
      const cursor = (before + spacer + textToInsert).length;
      try { input.setSelectionRange(cursor, cursor); } catch (_) {}
      input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
      input.dispatchEvent(new Event("change", { bubbles: true }));
      return true;
    }

    // contenteditable/ProseMirror-style prompt boxes used by modern AI sites.
    const selection = window.getSelection();
    if (selection && input.contains(selection.anchorNode)) {
      document.execCommand("insertText", false, textToInsert);
    } else {
      const existing = cleanText(input.innerText || input.textContent || "");
      const spacer = existing && !/\s$/.test(existing) ? " " : "";
      input.focus();
      try {
        const range = document.createRange();
        range.selectNodeContents(input);
        range.collapse(false);
        selection.removeAllRanges();
        selection.addRange(range);
      } catch (_) {}
      document.execCommand("insertText", false, spacer + textToInsert);
    }

    input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
    input.dispatchEvent(new Event("change", { bubbles: true }));
    return true;
  }


  function normalizeVoiceAgentCommand(text) {
    return cleanText(text || "")
      .toLowerCase()
      .replace(/[^a-z0-9\s]/g, " ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function parseSendMessageClosingCommand(text) {
    const original = cleanText(text || "");
    const normalized = normalizeVoiceAgentCommand(original);

    // Only trigger when the closing command phrase is at the very end of the transcript.
    // Accept both "send message" and Whisper's common "send a message" transcription.
    // Punctuation and capitalization are ignored.
    const normalizedCommandPattern = /(?:^|\s)send\s+(?:a\s+)?message$/;
    const originalCommandPattern = /(?:^|\s+)send\s+(?:a\s+)?message[\s.!?,;:]*$/i;

    if (!normalizedCommandPattern.test(normalized)) {
      return { shouldSend: false, promptText: original };
    }

    const promptText = cleanText(original.replace(originalCommandPattern, ""));
    return { shouldSend: true, promptText };
  }

  function getPromptInputText(input) {
    if (!input) return "";
    if (input.tagName === "TEXTAREA" || input.tagName === "INPUT") {
      return cleanText(input.value || "");
    }
    return cleanText(input.innerText || input.textContent || "");
  }

  function isDisabledButton(button) {
    if (!button) return true;
    return Boolean(
      button.disabled ||
      button.getAttribute("aria-disabled") === "true" ||
      button.getAttribute("disabled") !== null ||
      button.closest("[aria-disabled='true']")
    );
  }

  function isDarkOrFilledButton(button) {
    try {
      const style = window.getComputedStyle(button);
      const bg = style.backgroundColor || "";
      const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i);
      if (!match) return false;
      const r = Number(match[1]);
      const g = Number(match[2]);
      const b = Number(match[3]);
      const a = match[4] === undefined ? 1 : Number(match[4]);
      if (a < 0.35) return false;
      const avg = (r + g + b) / 3;
      return avg < 140;
    } catch (_) {
      return false;
    }
  }

  function looksLikeSendButton(button, promptInput, allowIconFallback = true) {
    if (!button || !isVisible(button) || isDisabledButton(button)) return false;

    const label = getLabel(button);
    const testid = (button.getAttribute("data-testid") || "").toLowerCase();
    const aria = (button.getAttribute("aria-label") || "").toLowerCase();
    const title = (button.getAttribute("title") || "").toLowerCase();
    const type = (button.getAttribute("type") || "").toLowerCase();
    const visibleText = cleanText(button.innerText || button.textContent || "").toLowerCase();
    const combined = `${label} ${testid} ${aria} ${title} ${visibleText}`.toLowerCase();

    // Never treat attachment/tool/model/mic/menu buttons as send buttons.
    // This protects ChatGPT's + button, intelligence/model picker, microphone, and similar Claude/DeepSeek controls.
    if (/add|attach|attachment|upload|file|photo|image|paperclip|plus|create|tool|tools|web search|deep research|more|menu|intelligence|instant|medium|high|model|gpt|voice|microphone|mic|audio|dictate/.test(combined)) {
      return false;
    }

    // Strong, explicit send-button signals.
    if (
      label === "send" ||
      label === "send prompt" ||
      label === "send message" ||
      label === "submit" ||
      testid === "send-button" ||
      testid === "send_button" ||
      testid.includes("send") ||
      aria === "send" ||
      aria === "send prompt" ||
      aria === "send message" ||
      aria.includes("send") ||
      aria.includes("submit") ||
      title.includes("send") ||
      type === "submit"
    ) {
      return true;
    }

    if (!allowIconFallback) return false;

    // Last-resort icon fallback for sites with unlabeled send buttons.
    // Must be icon-only, near the composer, and on the right side.
    if (promptInput) {
      const buttonRect = button.getBoundingClientRect?.();
      const promptRect = promptInput.getBoundingClientRect?.();
      const nearPrompt = Boolean(buttonRect && promptRect &&
        buttonRect.left >= promptRect.left - 60 &&
        buttonRect.right <= promptRect.right + 180 &&
        buttonRect.top >= promptRect.top - 180 &&
        buttonRect.bottom <= promptRect.bottom + 180
      );
      const rightSide = Boolean(buttonRect && promptRect &&
        buttonRect.left >= promptRect.left + (promptRect.width * 0.65)
      );
      const iconButton = Boolean(button.querySelector("svg") || String(button.innerHTML || "").includes("svg"));
      const iconOnly = !/[a-z0-9]{2,}/i.test(visibleText);
      const filledSendStyle = isDarkOrFilledButton(button);

      if (nearPrompt && rightSide && iconButton && iconOnly && filledSendStyle) return true;
    }

    return false;
  }

  function findSendButtonForPrompt(promptInput) {
    const scopes = [];
    const form = promptInput?.closest?.("form");
    if (form) scopes.push(form);
    const main = document.querySelector("main");
    if (main) scopes.push(main);
    scopes.push(document);

    for (const scope of scopes) {
      const buttons = querySelectorAllDeep("button,[role='button']", scope);

      // First pass: only explicit send labels/test IDs.
      const exact = buttons.find(button => looksLikeSendButton(button, promptInput, false));
      if (exact) return exact;

      // Second pass: icon-only fallback. Pick the rightmost candidate near the composer.
      const fallbackCandidates = buttons
        .filter(button => looksLikeSendButton(button, promptInput, true))
        .sort((a, b) => {
          const ar = a.getBoundingClientRect?.() || { right: 0, left: 0 };
          const br = b.getBoundingClientRect?.() || { right: 0, left: 0 };
          if (br.right !== ar.right) return br.right - ar.right;
          return br.left - ar.left;
        });

      if (fallbackCandidates.length) return fallbackCandidates[0];
    }

    return null;
  }

  function clickSendButtonLikeUser(button) {
    if (!button) return false;
    try { button.focus?.(); } catch (_) {}

    // Use one clean click only. Multiple synthetic pointer/mouse/click events can make
    // some AI sites briefly render duplicate optimistic messages.
    try {
      button.click();
      return true;
    } catch (_) {}

    try {
      button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
      return true;
    } catch (_) {}

    return false;
  }

  async function waitForSendButton(promptInput, timeoutMs = 2500) {
    const started = Date.now();
    let lastButton = null;

    while (Date.now() - started < timeoutMs) {
      const button = findSendButtonForPrompt(promptInput);
      if (button) {
        lastButton = button;
        if (!isDisabledButton(button)) return button;
      }
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    return lastButton && !isDisabledButton(lastButton) ? lastButton : null;
  }

  async function submitPromptByVoiceAgent() {
    const input = findPromptInput();
    if (!input) {
      alert("RITS Voice Agent: could not find the AI prompt box on this page.");
      return false;
    }

    if (!getPromptInputText(input)) {
      alert("RITS Voice Agent: nothing to send.");
      return false;
    }

    input.focus();

    // Give the AI page time to react to the inserted text and enable its send button.
    const button = await waitForSendButton(input, 2500);
    if (button) {
      clickSendButtonLikeUser(button);
      return true;
    }

    const form = input.closest?.("form");
    if (form && typeof form.requestSubmit === "function") {
      try {
        form.requestSubmit();
        return true;
      } catch (_) {}
    }

    return pressEnterToSubmit(input);
  }

  async function handleVoiceAgentSendCommand(transcript) {
    const command = parseSendMessageClosingCommand(transcript);
    if (!command.shouldSend) return false;

    const inputBefore = findPromptInput();
    const existingText = getPromptInputText(inputBefore);

    if (command.promptText) {
      const inserted = insertTextIntoPrompt(command.promptText);
      if (!inserted) return true;
    } else if (!existingText) {
      alert("RITS Voice Agent: nothing to send.");
      return true;
    }

    const submitted = await submitPromptByVoiceAgent();
    if (submitted) {
      console.log("[AI Voice Bridge] Voice Agent command executed: closing send message");
    }
    return true;
  }

  function mergeFloat32Chunks(chunks) {
    const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
    const merged = new Float32Array(totalLength);
    let offset = 0;
    for (const chunk of chunks) {
      merged.set(chunk, offset);
      offset += chunk.length;
    }
    return merged;
  }

  function downsampleBuffer(buffer, inputRate, outputRate) {
    if (outputRate === inputRate) return buffer;
    if (outputRate > inputRate) return buffer;

    const ratio = inputRate / outputRate;
    const newLength = Math.round(buffer.length / ratio);
    const result = new Float32Array(newLength);
    let offsetResult = 0;
    let offsetBuffer = 0;

    while (offsetResult < result.length) {
      const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
      let accum = 0;
      let count = 0;
      for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
        accum += buffer[i];
        count++;
      }
      result[offsetResult] = count ? accum / count : 0;
      offsetResult++;
      offsetBuffer = nextOffsetBuffer;
    }

    return result;
  }

  function encodeWav(samples, sampleRate) {
    const bytesPerSample = 2;
    const numChannels = 1;
    const blockAlign = numChannels * bytesPerSample;
    const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
    const view = new DataView(buffer);

    function writeString(offset, string) {
      for (let i = 0; i < string.length; i++) {
        view.setUint8(offset + i, string.charCodeAt(i));
      }
    }

    writeString(0, "RIFF");
    view.setUint32(4, 36 + samples.length * bytesPerSample, true);
    writeString(8, "WAVE");
    writeString(12, "fmt ");
    view.setUint32(16, 16, true);
    view.setUint16(20, 1, true);
    view.setUint16(22, numChannels, true);
    view.setUint32(24, sampleRate, true);
    view.setUint32(28, sampleRate * blockAlign, true);
    view.setUint16(32, blockAlign, true);
    view.setUint16(34, 16, true);
    writeString(36, "data");
    view.setUint32(40, samples.length * bytesPerSample, true);

    let offset = 44;
    for (let i = 0; i < samples.length; i++, offset += 2) {
      const s = Math.max(-1, Math.min(1, samples[i]));
      view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
    }

    return new Blob([view], { type: "audio/wav" });
  }

  function applyDictationIndicatorMode(el, mode = "recording") {
    if (!el) return;
    if (mode === "transcribing") {
      el.style.background = "#f59e0b";
      el.style.color = "#111827";
    } else {
      el.style.background = "#b91c1c";
      el.style.color = "#fff";
    }
  }

  function showDictationIndicator(message = "Recording local dictation...", mode = "recording") {
    hideDictationIndicator();
    const el = document.createElement("div");
    el.id = "rits-local-dictation-indicator";
    el.textContent = message;
    el.style.cssText = [
      "position: fixed",
      "right: 18px",
      "bottom: 18px",
      "z-index: 2147483647",
      "font: 700 14px/1.3 system-ui, -apple-system, Segoe UI, sans-serif",
      "padding: 12px 14px",
      "border-radius: 10px",
      "box-shadow: 0 8px 30px rgba(0,0,0,.35)",
      "max-width: 380px"
    ].join(";");
    applyDictationIndicatorMode(el, mode);
    document.documentElement.appendChild(el);
    ritsDictation.indicator = el;
  }

  function updateDictationIndicator(message, mode) {
    if (ritsDictation.indicator) {
      ritsDictation.indicator.textContent = message;
      if (mode) applyDictationIndicatorMode(ritsDictation.indicator, mode);
    }
  }

  function hideDictationIndicator() {
    if (ritsDictation.indicator) {
      ritsDictation.indicator.remove();
      ritsDictation.indicator = null;
    }
  }

  async function startLocalDictation() {
    if (ritsDictation.recording) return;

    try {
      // Barge-in behavior: starting dictation should immediately stop any
      // current read-aloud, so the user can interrupt and talk naturally.
      await stopVoice("dictation started");

      const stream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
          channelCount: 1
        }
      });

      const AudioContextClass = window.AudioContext || window.webkitAudioContext;
      if (!AudioContextClass) {
        throw new Error("This browser does not support AudioContext recording.");
      }

      const audioContext = new AudioContextClass();
      const source = audioContext.createMediaStreamSource(stream);
      const processor = audioContext.createScriptProcessor(4096, 1, 1);
      const silentGain = audioContext.createGain();
      silentGain.gain.value = 0;

      ritsDictation = {
        recording: true,
        stopping: false,
        stream,
        audioContext,
        source,
        processor,
        silentGain,
        chunks: [],
        startedAt: Date.now(),
        autoStopTimer: null,
        indicator: null
      };

      processor.onaudioprocess = (event) => {
        if (!ritsDictation.recording) return;
        const input = event.inputBuffer.getChannelData(0);
        ritsDictation.chunks.push(new Float32Array(input));
      };

      source.connect(processor);
      processor.connect(silentGain);
      silentGain.connect(audioContext.destination);

      ritsDictation.autoStopTimer = setTimeout(() => {
        if (ritsDictation.recording) {
          stopLocalDictation("Auto-stopped at 5 minutes");
        }
      }, RITS_DICTATION_MAX_SECONDS * 1000);

      showDictationIndicator("RITS Dictation: recording... Click Stop Recording, press Enter, or use Cmd+Shift+V to stop.", "recording");
      updateDictationButton("Stop Recording", "recording");
      setTimeout(() => {
        try {
          if (ritsDictationButton && ritsDictation.recording) ritsDictationButton.focus();
        } catch (_) {}
      }, 0);
      console.log("[AI Voice Bridge] Local dictation recording started.");
    } catch (err) {
      console.error("[AI Voice Bridge] Local dictation failed to start:", err);
      hideDictationIndicator();
      alert("RITS Dictation could not access the microphone. Allow microphone access in Chrome, then try again.");
    }
  }

  async function stopLocalDictation(reason = "stop") {
    if (!ritsDictation.recording) return;

    const state = ritsDictation;
    if (state.stopping) return;
    state.stopping = true;
    clearTimeout(state.autoStopTimer);

    // Give Chrome one short tail window to deliver the final audio buffer.
    // This helps preserve closing command words like "send message" when the user stops quickly.
    updateDictationIndicator("RITS Dictation: finishing recording...", "transcribing");
    updateDictationButton("Finishing...", "transcribing");
    await new Promise(resolve => setTimeout(resolve, RITS_DICTATION_STOP_TAIL_MS));

    state.recording = false;
    updateDictationIndicator("RITS Dictation: transcribing locally...", "transcribing");
    updateDictationButton("Transcribing...", "transcribing");

    try { state.processor && state.processor.disconnect(); } catch (_) {}
    try { state.source && state.source.disconnect(); } catch (_) {}
    try { state.silentGain && state.silentGain.disconnect(); } catch (_) {}
    try { state.stream && state.stream.getTracks().forEach(track => track.stop()); } catch (_) {}

    const inputRate = state.audioContext ? state.audioContext.sampleRate : 48000;
    try { state.audioContext && await state.audioContext.close(); } catch (_) {}

    try {
      const raw = mergeFloat32Chunks(state.chunks || []);
      if (!raw.length) {
        hideDictationIndicator();
        alert("RITS Dictation: no audio captured.");
        return;
      }

      const downsampled = downsampleBuffer(raw, inputRate, RITS_DICTATION_TARGET_RATE);
      const wavBlob = encodeWav(downsampled, RITS_DICTATION_TARGET_RATE);

      console.log("[AI Voice Bridge] Sending local WAV for transcription:", wavBlob.size, "bytes", reason);

      const response = await fetch(TRANSCRIBE_URL, {
        method: "POST",
        headers: { "Content-Type": "audio/wav" },
        body: wavBlob
      });

      let result = null;
      try {
        result = await response.json();
      } catch (_) {}

      if (!response.ok || !result || !result.ok) {
        const error = result?.error || `HTTP ${response.status}`;
        throw new Error(error);
      }

      const transcript = cleanText(result.text || "");
      if (!transcript) {
        hideDictationIndicator();
        alert("RITS Dictation: Local dictation returned an empty transcript.");
        return;
      }

      const voiceAgentHandled = await handleVoiceAgentSendCommand(transcript);
      if (voiceAgentHandled) {
        hideDictationIndicator();
        return;
      }

      const inserted = insertTextIntoPrompt(transcript);
      hideDictationIndicator();

      if (inserted) {
        console.log("[AI Voice Bridge] Local dictation inserted:", transcript);
      }
    } catch (err) {
      console.error("[AI Voice Bridge] Local dictation transcription failed:", err);
      hideDictationIndicator();

      const detail = (err?.message || err || "Unknown error").toString();
      if (/failed to fetch|networkerror|load failed|connection refused|could not connect/i.test(detail)) {
        alert([
          "RITS Dictation failed: Could not reach the local AI Voice Bridge app.",
          "",
          "Make sure the AI Voice Bridge application/terminal is running, then try again.",
          "",
          "Technical detail: " + detail
        ].join("\n"));
      } else {
        alert([
          "RITS Dictation failed.",
          "",
          "Make sure the AI Voice Bridge application/terminal is running, then try again.",
          "",
          "Technical detail: " + detail
        ].join("\n"));
      }
    } finally {
      ritsDictation = {
        recording: false,
        stopping: false,
        stream: null,
        audioContext: null,
        source: null,
        processor: null,
        silentGain: null,
        chunks: [],
        startedAt: 0,
        autoStopTimer: null,
        indicator: null
      };
      updateDictationButton("Dictate", false);
    }
  }

  function isLocalDictationHotkey(event) {
    const key = (event.key || "").toLowerCase();
    const code = event.code || "";

    // Mac: one dictation hotkey only.
    return event.metaKey && event.shiftKey && !event.ctrlKey && !event.altKey && (code === "KeyV" || key === "v");
  }

  function toggleLocalDictation(source = "manual") {
    if (ritsDictation.recording) {
      stopLocalDictation(source);
    } else {
      startLocalDictation();
    }
  }

  function installLocalDictationHotkey() {
    let lastHotkeyAt = 0;

    function handler(event) {
      if (!isLocalDictationHotkey(event)) return;

      const now = Date.now();
      if (now - lastHotkeyAt < 300) return;
      lastHotkeyAt = now;

      event.preventDefault();
      event.stopPropagation();
      event.stopImmediatePropagation();

      toggleLocalDictation("hotkey");
      return false;
    }

    function add(target) {
      if (!target) return;
      // Hotkey must be handled on keydown only. Handling keyup too can
      // double-toggle dictation: start on keydown, then stop on keyup.
      target.addEventListener("keydown", handler, true);
    }

    add(window);
    add(document);
    add(document.documentElement);
    setTimeout(() => add(document.body), 500);

    console.log("[AI Voice Bridge] Dictation hotkey installed: Cmd+Shift+V");
  }

  function installEnterToStopDictation() {
    function handler(event) {
      if (!ritsDictation.recording || ritsDictation.stopping) return;
      if (event.key !== "Enter") return;
      if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;

      event.preventDefault();
      event.stopPropagation();
      event.stopImmediatePropagation();
      stopLocalDictation("enter");
      return false;
    }

    window.addEventListener("keydown", handler, true);
    document.addEventListener("keydown", handler, true);
    document.documentElement.addEventListener("keydown", handler, true);
    setTimeout(() => document.body && document.body.addEventListener("keydown", handler, true), 500);

    console.log("[AI Voice Bridge] Enter-to-stop dictation installed.");
  }

  function updateDictationButton(label, mode) {
    if (!ritsDictationButton) return;
    const state = mode === true ? "recording" : (mode === false || !mode ? "idle" : mode);
    const isRecording = state === "recording";
    const isTranscribing = state === "transcribing";

    ritsDictationButton.innerHTML = RITS_MIC_ICON_SVG;
    ritsDictationButton.title = isRecording
      ? "Stop RITS local dictation. Press Enter to stop."
      : isTranscribing
        ? "RITS local dictation is transcribing..."
        : "Start RITS local dictation. Hotkey: Cmd+Shift+V.";
    ritsDictationButton.setAttribute("aria-label", isRecording ? "Stop local dictation" : "Start local dictation");
    ritsDictationButton.style.background = isTranscribing ? "#f59e0b" : (isRecording ? "#dc2626" : "#2563eb");
    ritsDictationButton.style.color = isTranscribing ? "#111827" : "#fff";
    ritsDictationButton.style.boxShadow = isRecording ? "0 0 0 4px rgba(220,38,38,.26)" : "none";
    ritsDictationButton.style.opacity = "1";
    positionDictationButton();
  }


  function getUnionRect(elements) {
    const rects = elements
      .map(el => el?.getBoundingClientRect?.())
      .filter(Boolean);
    if (!rects.length) return null;
    const left = Math.min(...rects.map(r => r.left));
    const right = Math.max(...rects.map(r => r.right));
    const top = Math.min(...rects.map(r => r.top));
    const bottom = Math.max(...rects.map(r => r.bottom));
    return {
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
      dockMode: "row"
    };
  }

  function getSiteSpecificDockRowRect(promptRect) {
    if (!promptRect) return null;

    let labels = [];
    if (SITE.id === "deepseek") {
      labels = ["deepthink", "search"];
    } else if (SITE.id === "claude") {
      labels = ["write", "create", "learn", "code", "life stuff"];
    } else {
      return null;
    }

    const controls = Array.from(document.querySelectorAll("button,[role='button'],a,div,span"));
    const hits = controls.filter(control => {
      if (!isVisible(control)) return false;
      const rect = control.getBoundingClientRect?.();
      if (!rect) return false;
      if (rect.width < 24 || rect.width > 260 || rect.height < 18 || rect.height > 70) return false;

      const text = cleanText(control.innerText || control.textContent || "").toLowerCase();
      if (!text || text.length > 40) return false;
      if (!labels.some(label => text.includes(label))) return false;

      const verticallyNearPrompt =
        rect.top >= promptRect.top - 60 &&
        rect.top <= promptRect.bottom + 230;
      const horizontallyNearPrompt =
        rect.right >= promptRect.left - 120 &&
        rect.left <= promptRect.right + 120;

      return verticallyNearPrompt && horizontallyNearPrompt;
    });

    if (!hits.length) return null;

    const rect = getUnionRect(hits);
    if (!rect || rect.width < 40 || rect.height < 20) return null;
    return rect;
  }

  function getClaudeComposerShellRect(promptInput, promptRect) {
    if (!promptInput || !promptRect) return null;

    const candidates = [];
    function addCandidate(item) {
      if (item && !candidates.includes(item)) candidates.push(item);
    }

    addCandidate(promptInput.closest?.("form"));
    addCandidate(promptInput.closest?.("[data-testid*='composer' i]"));
    addCandidate(promptInput.closest?.("[class*='composer' i]"));
    addCandidate(promptInput.closest?.("[class*='prompt' i]"));
    addCandidate(promptInput.closest?.("[class*='input' i]"));

    let node = promptInput;
    let depth = 0;
    while (node && node !== document.body && node !== document.documentElement && depth < 12) {
      addCandidate(node);
      node = node.parentElement;
      depth += 1;
    }

    const promptCenterX = promptRect.left + (promptRect.width / 2);
    const promptCenterY = promptRect.top + (promptRect.height / 2);
    let best = null;
    let bestScore = -Infinity;

    for (const candidate of candidates) {
      const rect = candidate?.getBoundingClientRect?.();
      if (!rect) continue;
      if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
      if (rect.width < 320 || rect.height < 48) continue;
      if (rect.width > Math.min(window.innerWidth * 0.88, 1100)) continue;
      if (rect.height > 220) continue;

      const containsPromptCenter =
        promptCenterX >= rect.left && promptCenterX <= rect.right &&
        promptCenterY >= rect.top && promptCenterY <= rect.bottom;
      if (!containsPromptCenter) continue;

      const leftExpansion = Math.max(0, promptRect.left - rect.left);
      const rightExpansion = Math.max(0, rect.right - promptRect.right);
      const bottomDistance = Math.abs(rect.bottom - promptRect.bottom);
      const topDistance = Math.abs(rect.top - promptRect.top);

      // Prefer the visible outer composer shell. It should expand left from the
      // inner text area enough to include Claude's plus/control row, without
      // jumping up to a huge page wrapper.
      const score =
        (leftExpansion * 5) +
        (rightExpansion * 1.5) +
        rect.width -
        (bottomDistance * 0.8) -
        (topDistance * 0.2) -
        Math.max(0, rect.height - 140);

      if (score > bestScore) {
        best = rect;
        bestScore = score;
      }
    }

    if (!best) return null;

    // If the best candidate is basically just the inner textbox, it will place
    // the Dictate button too close to the composer border. Reject it so the
    // caller can decide whether to fall back.
    if ((promptRect.left - best.left) < 12) return null;

    return best;
  }

  function getComposerDockRect() {
    let promptInput = findPromptInput();
    if ((!promptInput || !isVisible(promptInput)) && SITE.id === "gemini") {
      const geminiShell = findGeminiComposerShell();
      if (geminiShell?.el) promptInput = geminiShell.el;
    }
    if (!promptInput || !isVisible(promptInput)) return null;

    const promptRect = promptInput.getBoundingClientRect?.();
    if (!promptRect) return null;
    const siteRowRect = getSiteSpecificDockRowRect(promptRect);
    if (siteRowRect) return siteRowRect;

    // Claude existing-conversation pages may not expose the new-chat quick-action
    // row, but the composer itself is still a safe anchor. Dock the Dictate
    // button to the left of the Claude message box instead of falling back to
    // the page/sidebar bottom-left area.
    if (SITE.id === "claude" || SITE.id === "gemini") {
      const shellRect = SITE.id === "claude"
        ? (getClaudeComposerShellRect(promptInput, promptRect) || promptRect)
        : promptRect;
      return {
        left: shellRect.left,
        right: shellRect.right,
        top: shellRect.top,
        bottom: shellRect.bottom,
        width: shellRect.width,
        height: shellRect.height,
        dockMode: "prompt-left"
      };
    }

    // For DeepSeek, do not half-dock to a guessed shell. Either the site-specific
    // row anchor is safe, or we use the stable bottom-left fallback.
    if (SITE.id !== "chatgpt") return null;

    const promptCenterX = promptRect.left + (promptRect.width / 2);
    const promptCenterY = promptRect.top + (promptRect.height / 2);

    const candidates = [];
    function addCandidate(item) {
      if (item && !candidates.includes(item)) candidates.push(item);
    }

    addCandidate(promptInput.closest?.("form"));
    addCandidate(promptInput.closest?.("[data-testid*='composer' i]"));
    addCandidate(promptInput.closest?.("[class*='composer' i]"));
    addCandidate(promptInput.closest?.("[class*='prompt' i]"));
    addCandidate(promptInput.closest?.("[class*='input' i]"));

    let node = promptInput;
    let depth = 0;
    while (node && node !== document.body && node !== document.documentElement && depth < 14) {
      addCandidate(node);
      node = node.parentElement;
      depth += 1;
    }

    let best = null;
    let bestScore = -Infinity;

    for (const candidate of candidates) {
      const rect = candidate?.getBoundingClientRect?.();
      if (!rect) continue;
      if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
      if (rect.width < 300 || rect.height < 44) continue;
      if (rect.width > Math.min(window.innerWidth * 0.88, 1150)) continue;
      if (rect.height > 360) continue;

      const containsPromptCenter =
        promptCenterX >= rect.left && promptCenterX <= rect.right &&
        promptCenterY >= rect.top && promptCenterY <= rect.bottom;
      if (!containsPromptCenter) continue;

      // Prefer the full composer shell, not the inner text area. DeepSeek/Claude often
      // put their tool/control row below the text input, so bottom extension is a strong signal.
      const bottomDistance = Math.abs(rect.bottom - promptRect.bottom);
      const bottomExtension = Math.max(0, rect.bottom - promptRect.bottom);
      const leftExpansion = Math.max(0, promptRect.left - rect.left);
      const widthScore = rect.width;
      const heightPenalty = Math.max(0, rect.height - 260) * 0.5;
      const score = widthScore + (leftExpansion * 1.5) + (bottomExtension * 4) - (bottomDistance * 0.4) - heightPenalty;

      if (score > bestScore) {
        best = rect;
        bestScore = score;
      }
    }

    return best;
  }

  function rectsOverlap(a, b) {
    return !(
      a.right <= b.left ||
      a.left >= b.right ||
      a.bottom <= b.top ||
      a.top >= b.bottom
    );
  }

  function wouldDockOverlapNativeControl(left, top, width, height) {
    const proposed = {
      left: left - 2,
      right: left + width + 2,
      top: top - 2,
      bottom: top + height + 2
    };

    const controls = Array.from(document.querySelectorAll("button,[role='button'],a,input,textarea,[contenteditable='true']"));
    for (const control of controls) {
      if (!control || control === ritsDictationButton || control === ritsReadButton || control === ritsFloatingControl || control.closest?.("#rits-local-voice-control") || control.id === "rits-local-dictation-button") continue;
      if (!isVisible(control)) continue;
      const rect = control.getBoundingClientRect?.();
      if (!rect || rect.width < 4 || rect.height < 4) continue;
      if (rectsOverlap(proposed, rect)) return true;
    }

    return false;
  }

  function getDictationFallbackLeft(buttonWidth) {
    const defaultLeft = 14;

    // Claude existing-conversation pages have a persistent left sidebar with
    // account/download controls at the bottom. When composer docking is not
    // available, keep the Dictate button out of that bottom-left control area.
    if (SITE.id !== "claude" && SITE.id !== "gemini") {
      return defaultLeft;
    }

    let sidebarRight = 0;
    const candidates = Array.from(document.querySelectorAll(
      "aside,nav,[data-testid*='sidebar'],[class*='sidebar'],[class*='side-bar']"
    ));

    for (const el of candidates) {
      if (!isVisible(el)) continue;
      const rect = el.getBoundingClientRect?.();
      if (!rect) continue;

      const looksLikeLeftSidebar =
        rect.left <= 40 &&
        rect.right > 80 &&
        rect.width >= 120 &&
        rect.width <= Math.min(420, window.innerWidth * 0.45) &&
        rect.height >= window.innerHeight * 0.45;

      if (looksLikeLeftSidebar) {
        sidebarRight = Math.max(sidebarRight, rect.right);
      }
    }

    if (!sidebarRight) {
      if (SITE.id === "gemini") {
        const size = getFloatingControlRectSize();
        return Math.min(Math.max(defaultLeft, 92), Math.max(defaultLeft, window.innerWidth - size.width - 14));
      }
      return defaultLeft;
    }

    const desiredLeft = Math.round(sidebarRight + 18);
    const maxLeft = Math.max(defaultLeft, window.innerWidth - buttonWidth - 14);
    return Math.min(Math.max(defaultLeft, desiredLeft), maxLeft);
  }

  function getStoredFloatingPosition() {
    try {
      const raw = localStorage.getItem(RITS_FLOATING_CONTROL_KEY);
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (!Number.isFinite(parsed?.left) || !Number.isFinite(parsed?.top)) return null;
      return parsed;
    } catch (_) {
      return null;
    }
  }

  function saveFloatingPosition(left, top) {
    try {
      localStorage.setItem(RITS_FLOATING_CONTROL_KEY, JSON.stringify({ left, top }));
    } catch (_) {}
  }

  function clearFloatingPosition() {
    try {
      localStorage.removeItem(RITS_FLOATING_CONTROL_KEY);
    } catch (_) {}
  }

  function getFloatingTarget() {
    return ritsFloatingControl || ritsDictationButton;
  }

  function getFloatingControlRectSize() {
    const target = getFloatingTarget();
    return {
      width: target?.offsetWidth || 118,
      height: target?.offsetHeight || 38
    };
  }

  function clampFloatingPosition(left, top) {
    const size = getFloatingControlRectSize();
    const margin = 10;
    const maxLeft = Math.max(margin, window.innerWidth - size.width - margin);
    const maxTop = Math.max(margin, window.innerHeight - size.height - margin);
    return {
      left: Math.round(Math.min(Math.max(margin, left), maxLeft)),
      top: Math.round(Math.min(Math.max(margin, top), maxTop))
    };
  }

  function applyFloatingPosition(left, top, persist = false) {
    const target = getFloatingTarget();
    if (!target) return;
    const clamped = clampFloatingPosition(left, top);
    target.style.left = `${clamped.left}px`;
    target.style.top = `${clamped.top}px`;
    target.style.right = "auto";
    target.style.bottom = "auto";
    if (persist) saveFloatingPosition(clamped.left, clamped.top);
  }

  function positionDictationButton() {
    const target = getFloatingTarget();
    if (!target) return;

    const stored = getStoredFloatingPosition();
    if (stored) {
      applyFloatingPosition(stored.left, stored.top, false);
      return;
    }

    const rect = getComposerDockRect();
    const buttonWidth = target.offsetWidth || 118;
    const buttonHeight = target.offsetHeight || 38;

    if (rect) {
      const gap = rect.dockMode === "prompt-left"
        ? (SITE.id === "gemini" ? 24 : 18)
        : (SITE.id === "deepseek" && rect.dockMode === "row")
          ? 22
          : 12;
      const bottomPadding = 14;
      const left = Math.round(rect.left - buttonWidth - gap);
      const top = (rect.dockMode === "row" || rect.dockMode === "prompt-left")
        ? Math.round(rect.top + (rect.height / 2) - (buttonHeight / 2))
        : Math.round(rect.bottom - buttonHeight - bottomPadding);
      const safeLeft = left >= 12;
      const safeTop = top >= 8 && (top + buttonHeight) <= (window.innerHeight - 8);
      const overlapsNativeControl = wouldDockOverlapNativeControl(left, top, buttonWidth, buttonHeight);

      if (safeLeft && safeTop && !overlapsNativeControl) {
        target.style.left = `${left}px`;
        target.style.top = `${top}px`;
        target.style.bottom = "auto";
        target.style.right = "auto";
        return;
      }
    }

    // Same stable fallback as the v2 master pack; only the target changed from a
    // single button to the floating Mic | Stop | Repeat control.
    target.style.left = `${getDictationFallbackLeft(buttonWidth)}px`;
    target.style.top = "auto";
    target.style.right = "auto";
    target.style.bottom = "14px";
  }


  function installDictationButtonPositioning() {
    function schedule() {
      if (installDictationButtonPositioning._pending) return;
      installDictationButtonPositioning._pending = true;
      requestAnimationFrame(() => {
        installDictationButtonPositioning._pending = false;
        positionDictationButton();
      });
    }

    window.addEventListener("resize", schedule, { passive: true });
    window.addEventListener("scroll", schedule, true);
    setInterval(schedule, 1000);
    schedule();
  }

  const RITS_MIC_ICON_SVG = `
    <svg aria-hidden="true" viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2.35" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
      <path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z"></path>
      <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
      <path d="M12 19v3"></path>
      <path d="M8 22h8"></path>
    </svg>`;

  function installFloatingDragBehavior() {
    if (!ritsFloatingControl || !ritsDragHandle) return;

    let pointerId = null;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    ritsDragHandle.addEventListener("pointerdown", (event) => {
      if (event.button !== undefined && event.button !== 0) return;
      event.preventDefault();
      event.stopPropagation();
      pointerId = event.pointerId;
      startX = event.clientX;
      startY = event.clientY;
      const rect = ritsFloatingControl.getBoundingClientRect();
      startLeft = rect.left;
      startTop = rect.top;
      ritsDragHandle.style.cursor = "grabbing";
      try { ritsDragHandle.setPointerCapture(pointerId); } catch (_) {}
    }, true);

    ritsDragHandle.addEventListener("pointermove", (event) => {
      if (pointerId === null || event.pointerId !== pointerId) return;
      event.preventDefault();
      event.stopPropagation();
      const dx = event.clientX - startX;
      const dy = event.clientY - startY;
      applyFloatingPosition(startLeft + dx, startTop + dy, false);
    }, true);

    function finishDrag(event) {
      if (pointerId === null || event.pointerId !== pointerId) return;
      event.preventDefault();
      event.stopPropagation();
      try { ritsDragHandle.releasePointerCapture(pointerId); } catch (_) {}
      pointerId = null;
      ritsDragHandle.style.cursor = "grab";
      const rect = ritsFloatingControl.getBoundingClientRect();
      const clamped = clampFloatingPosition(rect.left, rect.top);
      applyFloatingPosition(clamped.left, clamped.top, true);
    }

    ritsDragHandle.addEventListener("pointerup", finishDrag, true);
    ritsDragHandle.addEventListener("pointercancel", finishDrag, true);

    ritsDragHandle.addEventListener("dblclick", (event) => {
      event.preventDefault();
      event.stopPropagation();
      clearFloatingPosition();
      positionDictationButton();
    }, true);
  }

  function installLocalDictationButton() {
    if (document.getElementById("rits-local-voice-control")) return;

    const control = document.createElement("div");
    control.id = "rits-local-voice-control";
    control.setAttribute("role", "group");
    control.setAttribute("aria-label", "AI Voice Bridge controls");
    control.title = "AI Voice Bridge";
    control.style.cssText = [
      "position: fixed",
      "left: 14px",
      "bottom: 14px",
      "z-index: 2147483647",
      "display: inline-flex",
      "align-items: stretch",
      "overflow: hidden",
      "border-radius: 999px",
      "border: 1px solid rgba(255,255,255,.24)",
      "box-shadow: 0 8px 24px rgba(0,0,0,.30)",
      "background: #111827",
      "font: 750 13px/1 system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
      "opacity: .92",
      "user-select: none"
    ].join(";");

    const micButton = document.createElement("button");
    micButton.id = "rits-local-dictation-button";
    micButton.type = "button";
    micButton.tabIndex = 0;
    micButton.innerHTML = RITS_MIC_ICON_SVG;
    micButton.title = "Start RITS local dictation. Hotkey: Cmd+Shift+V.";
    micButton.setAttribute("aria-label", "Start local dictation");
    micButton.style.cssText = [
      "width: 46px",
      "min-width: 46px",
      "height: 42px",
      "display: inline-flex",
      "align-items: center",
      "justify-content: center",
      "background: #2563eb",
      "color: #fff",
      "border: 0",
      "padding: 0",
      "margin: 0",
      "cursor: pointer",
      "font: inherit",
      "appearance: none"
    ].join(";");

    const dragHandle = document.createElement("div");
    dragHandle.id = "rits-floating-drag-handle";
    dragHandle.title = "Drag to move. Double-click to reset.";
    dragHandle.setAttribute("aria-hidden", "true");
    dragHandle.style.cssText = [
      "width: 9px",
      "min-width: 9px",
      "height: 42px",
      "display: inline-flex",
      "align-items: center",
      "justify-content: center",
      "background: #111827",
      "border-left: 1px solid rgba(255,255,255,.20)",
      "border-right: 1px solid rgba(255,255,255,.20)",
      "cursor: grab",
      "touch-action: none"
    ].join(";");

    const stopButton = document.createElement("button");
    stopButton.id = "rits-stop-readback-button";
    stopButton.type = "button";
    stopButton.tabIndex = 0;
    stopButton.textContent = "⏹";
    stopButton.title = "Stop current read-aloud. Same as Esc.";
    stopButton.setAttribute("aria-label", "Stop read aloud");
    stopButton.style.cssText = [
      "width: 46px",
      "min-width: 46px",
      "height: 42px",
      "display: inline-flex",
      "align-items: center",
      "justify-content: center",
      "background: #dc2626",
      "color: #fff",
      "border: 0",
      "border-left: 1px solid rgba(255,255,255,.14)",
      "padding: 0",
      "margin: 0",
      "cursor: pointer",
      "font: 850 15px/1 system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
      "appearance: none"
    ].join(";");

    const readButton = document.createElement("button");
    readButton.id = "rits-repeat-last-response-button";
    readButton.type = "button";
    readButton.tabIndex = 0;
    readButton.textContent = "↻";
    readButton.title = "Repeat latest response aloud";
    readButton.setAttribute("aria-label", "Repeat latest response aloud");
    readButton.style.cssText = [
      "width: 46px",
      "min-width: 46px",
      "height: 42px",
      "display: inline-flex",
      "align-items: center",
      "justify-content: center",
      "background: #16a34a",
      "color: #fff",
      "border: 0",
      "border-left: 1px solid rgba(255,255,255,.14)",
      "padding: 0",
      "margin: 0",
      "cursor: pointer",
      "font: 900 19px/1 system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
      "appearance: none"
    ].join(";");

    micButton.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      toggleLocalDictation("button");
    }, true);

    stopButton.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      stopManualReadback("strip stop button");
    }, true);

    readButton.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      readLastResponseAloud();
    }, true);

    control.appendChild(micButton);
    control.appendChild(dragHandle);
    control.appendChild(readButton);
    control.appendChild(stopButton);
    document.documentElement.appendChild(control);

    ritsFloatingControl = control;
    ritsDictationButton = micButton;
    ritsStopButton = stopButton;
    ritsReadButton = readButton;
    ritsDragHandle = dragHandle;

    installFloatingDragBehavior();
    installDictationButtonPositioning();
    updateDictationButton("Dictate", false);
    updateReadButtonState("ready");

    console.log("[AI Voice Bridge] Floating Mic | Repeat | Stop control installed.");
  }


  installHardEscStop();
  installLocalDictationHotkey();
  installEnterToStopDictation();
  installLocalDictationButton();

  if (SITE.id === "chatgpt") {
    initChatGPTConnector();
  } else {
    initGenericConnector();
  }
})();
