JanitorV5

Supercharge JanitorAI with Smart Reply, auto-summaries, scene context injection, style presets, advanced prompting, and encrypted community chat. Supports OpenRouter, OpenAI, Anthropic, Groq, Gemini & more. PIN-protected API key storage, Double Ratchet forward secrecy, per-room AES-256-GCM encryption, and persona library — all in one script.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         JanitorV5
// @namespace    https://janitorai.com/
// @version      5.7.9
// @description  Supercharge JanitorAI with Smart Reply, auto-summaries, scene context injection, style presets, advanced prompting, and encrypted community chat. Supports OpenRouter, OpenAI, Anthropic, Groq, Gemini & more. PIN-protected API key storage, Double Ratchet forward secrecy, per-room AES-256-GCM encryption, and persona library — all in one script.
// @author       eivls + JanitorV5
// @license      All Rights Reserved
// @match        https://janitorai.com/*
// @match        https://www.janitorai.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      openrouter.ai
// @connect      api.openai.com
// @connect      api.x.ai
// @connect      api.mistral.ai
// @connect      api.groq.com
// @connect      api.anthropic.com
// @connect      ntfy.sh
// @connect      api.cohere.ai
// @connect      generativelanguage.googleapis.com
// @connect      api.together.xyz
// @connect      api.deepseek.com
// @connect      inference.cerebras.ai
// @connect      lorebary.com
// @connect      self
// NOTE FOR REVIEWERS: No @connect wildcard is used. Every outbound domain is
// listed explicitly above. If you use a custom API proxy not listed here,
// Tampermonkey will block the request — add your proxy domain to your own
// local copy, or request it be added via the GitHub issue tracker.
// All custom endpoints are also validated at runtime: HTTPS-only, no private
// IP ranges (SSRF guard), and PIN-protected sessions bind the key to the
// endpoint that was active at unlock time.
// @run-at       document-idle
// ==/UserScript==

// Copyright (c) 2025 eivls. All Rights Reserved.
//
// This script is the intellectual property of eivls.
// Copying, modifying, redistributing, or republishing this script
// — in whole or in part — without prior written permission from the
// author is strictly prohibited.
//
// To request permission, contact the author via GreasyFork.
//

(async function () {
  'use strict';

  // JanitorV5 Ultimate v5.7.4 — Self-healing, multi-fallback, deep React source connected, production-grade structure

  // ─── STORAGE ───────────────────────────────────────────────────────────────

  const _cfgCache = {};

  /**
   * Reads a GM storage value, returning `d` as default.
   * Results are memoised in `_cfgCache` for the lifetime of the page.
   * @param {string} k - Storage key.
   * @param {*}      d - Default value if the key is unset.
   * @returns {*}
   */
  const gget = (k, d) => {
    if (k in _cfgCache) return _cfgCache[k];
    try { _cfgCache[k] = GM_getValue(k, d); return _cfgCache[k]; } catch { return d; }
  };

  /**
   * Writes a value to GM storage and updates the in-memory cache atomically.
   * @param {string} k - Storage key.
   * @param {*}      v - Value to persist.
   */
  const gset = (k, v) => {
    _cfgCache[k] = v;
    try { GM_setValue(k, v); } catch {  }
  };

  // ─── AES-GCM API KEY ENCRYPTION (PIN-PROTECTED AT REST) ──────────────────
  // Problem: GM_getValue stores data as plaintext. Any other installed Tampermonkey
  // script on janitorai.com that also has GM_getValue can read the API key directly.
  //
  // Solution: Encrypt the API key with AES-256-GCM using a key derived from a
  // user-set session PIN via PBKDF2-SHA-256 (310,000 iterations — OWASP 2024 rec).
  // The PIN is NEVER stored — only held in memory for the browser session.
  // The ciphertext in GM storage is useless without the PIN.
  //
  // Flow:
  //   First time: user pastes key + sets PIN → key encrypted + stored as ciphertext
  //   Subsequent loads: user enters PIN → key decrypted from ciphertext into memory
  //   If PIN not set: key stored plaintext (same as before) with a visible warning
  //
  // GM keys:
  //   ms2_apiKey_enc  — base64(iv + ciphertext + authTag)  — set when PIN active
  //   ms2_apiKey      — plaintext fallback                  — set when no PIN
  //   ms2_apiKey_salt — 16-byte random salt for PBKDF2      — always set

  const _KEY_ENC_GM   = 'ms2_apiKey_enc';
  const _KEY_SALT_GM  = 'ms2_apiKey_salt';
  const _KEY_PLAIN_GM = 'ms2_apiKey';
  // Upgraded from SHA-256/310k to SHA-512/350k — aligns with community chat KDF strength.
  // SHA-512 is ~2× slower than SHA-256 per iteration on 32-bit CPUs, making
  // offline brute-force of captured ciphertext more expensive.
  const _PBKDF2_ITERS = 350000;
  const _PBKDF2_HASH  = 'SHA-512';

  // In-memory session store — holds the derived CryptoKey for the session.
  // The raw PIN string is NEVER retained after key derivation; only the
  // CryptoKey (non-extractable) lives here. This limits the blast radius if
  // page-level JS can somehow read the closure scope.
  const _pinSession = { active: false, cryptoKey: null };

  function _b64ToArr(b64) {
    const bin = atob(b64); const arr = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr;
  }
  function _arrToB64(arr) {
    // String.fromCharCode(...arr) with spread can stack-overflow on large buffers.
    // Process in 8 KB chunks to stay well within the call-stack limit.
    const u8 = new Uint8Array(arr);
    let str = '';
    const CHUNK = 8192;
    for (let i = 0; i < u8.length; i += CHUNK) {
      str += String.fromCharCode(...u8.subarray(i, i + CHUNK));
    }
    return btoa(str);
  }

  async function _pinDeriveKey(pin, saltArr) {
    const enc = new TextEncoder();
    const rawKey = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey']);
    return crypto.subtle.deriveKey(
      { name: 'PBKDF2', hash: _PBKDF2_HASH, salt: saltArr, iterations: _PBKDF2_ITERS },
      rawKey,
      { name: 'AES-GCM', length: 256 },
      false, ['encrypt', 'decrypt']
    );
  }

  async function _pinEncryptKey(apiKey, pin) {
    try {
      let saltArr;
      const storedSalt = (() => { try { return GM_getValue(_KEY_SALT_GM, ''); } catch { return ''; } })();
      saltArr = storedSalt ? _b64ToArr(storedSalt) : crypto.getRandomValues(new Uint8Array(32));
      if (!storedSalt) { try { GM_setValue(_KEY_SALT_GM, _arrToB64(saltArr)); } catch {} }

      const ck = await _pinDeriveKey(pin, saltArr);
      const iv = crypto.getRandomValues(new Uint8Array(12));
      const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ck, new TextEncoder().encode(apiKey));
      // Store: iv(12) + ciphertext
      const blob = new Uint8Array(12 + ct.byteLength);
      blob.set(iv, 0); blob.set(new Uint8Array(ct), 12);
      try { GM_setValue(_KEY_ENC_GM, _arrToB64(blob)); } catch {}
      try { GM_deleteValue(_KEY_PLAIN_GM); } catch {} // remove plaintext
      _pinSession.active = true; _pinSession.cryptoKey = ck;
      // PIN string is intentionally NOT stored — only the derived CryptoKey is kept
      delete _cfgCache[_KEY_PLAIN_GM]; // invalidate plaintext cache
      return true;
    } catch (e) { console.warn('[JV5-PIN] Encrypt failed:', e.message); return false; }
  }

  async function _pinDecryptKey(pin) {
    try {
      const blob64 = (() => { try { return GM_getValue(_KEY_ENC_GM, ''); } catch { return ''; } })();
      if (!blob64) return null;
      const salt64 = (() => { try { return GM_getValue(_KEY_SALT_GM, ''); } catch { return ''; } })();
      if (!salt64) return null;
      const blob = _b64ToArr(blob64); const saltArr = _b64ToArr(salt64);
      const iv = blob.slice(0, 12); const ct = blob.slice(12);
      const ck = await _pinDeriveKey(pin, saltArr);
      const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, ck, ct);
      const key = new TextDecoder().decode(plain);
      _pinSession.active = true; _pinSession.cryptoKey = ck;
      // PIN is discarded after key derivation — only the non-extractable CryptoKey is kept
      return key;
    } catch { return null; } // wrong PIN → AES-GCM auth tag fails → returns null
  }

  function _pinIsActive() {
    // Returns true if the user has set a PIN (encrypted ciphertext exists in GM)
    try { return !!GM_getValue(_KEY_ENC_GM, ''); } catch { return false; }
  }

  async function _pinRemove() {
    // Decrypt current key using the session CryptoKey (no raw PIN needed),
    // re-store as plaintext, then wipe encrypted fields.
    try {
      let key = null;
      if (_pinSession.active && _pinSession.cryptoKey) {
        try {
          const blob64 = (() => { try { return GM_getValue(_KEY_ENC_GM, ''); } catch { return ''; } })();
          if (blob64) {
            const blob = _b64ToArr(blob64);
            const iv = blob.slice(0, 12); const ct = blob.slice(12);
            const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, _pinSession.cryptoKey, ct);
            key = new TextDecoder().decode(plain);
          }
        } catch { /* wrong key or corrupt — proceed with removal anyway */ }
      }
      if (key) { try { GM_setValue(_KEY_PLAIN_GM, key); } catch {} }
      try { GM_deleteValue(_KEY_ENC_GM); } catch {}
      try { GM_deleteValue(_KEY_SALT_GM); } catch {}
      _pinSession.active = false; _pinSession.cryptoKey = null; _sessionApiKey = ''; _sessionEndpointBinding = '';
      return true;
    } catch { return false; }
  }

  // In-memory API key for PIN-protected sessions.
  // When PIN is active, the decrypted key lives here ONLY — it is never
  // written back to GM storage (which would defeat the encryption).
  // Survives only for the current page session; cleared on lock/logout.
  let _sessionApiKey = '';
  // The endpoint that was active when the API key was decrypted/set this session.
  // If the endpoint changes to something different before the next callAPI, we
  // warn the user — this prevents a scenario where an attacker (or a bug) swaps
  // the endpoint to a hostile server and harvests the key from the Authorization header.
  let _sessionEndpointBinding = '';

  // Returns the API key — from the in-memory session store when PIN is active,
  // otherwise from GM plaintext (no PIN set).
  function _getApiKey() {
    if (_pinSession.active) return _sessionApiKey;
    return gget(_KEY_PLAIN_GM, '');
  }

  // ─── PIN SESSION IDLE AUTO-LOCK (forced re-auth after inactivity) ──────────
  // When a PIN is active, the decrypted API key sits in memory (_sessionApiKey)
  // for the rest of the page session — by design ("once per browser session").
  // If the user steps away from an unlocked tab, that key stays resident in
  // memory indefinitely. This auto-lock clears _sessionApiKey and the derived
  // CryptoKey after N minutes of no user activity, forcing PIN re-entry before
  // the key can be used again — limiting the window an unlocked key is exposed
  // to anything that could otherwise read this closure's memory.
  //
  // Off by default (idleMin = 0 → disabled). User can enable 5/15/30/60-minute
  // auto-lock from the General settings tab, next to the PIN status indicator.
  const _PIN_IDLE_GM = 'jv5_pin_idle_min';
  let _pinLastActivity = Date.now();
  let _pinIdleInterval = null;

  /** Reads the configured idle-lock timeout in minutes (0 = disabled). */
  function _pinGetIdleMin() {
    try {
      const v = Number(GM_getValue(_PIN_IDLE_GM, 0));
      return Number.isFinite(v) && v >= 0 ? v : 0;
    } catch { return 0; }
  }

  function _pinTouchActivity() {
    _pinLastActivity = Date.now();
  }

  /**
   * Forcibly forgets the in-memory decrypted API key and derived CryptoKey,
   * requiring PIN re-entry before the key can be used again. No-op if no
   * PIN session is currently unlocked.
   * @param {string} [reason] - Short reason shown in the toast (e.g. "inactive").
   */
  function _pinLockNow(reason) {
    if (!_pinSession.active) return;
    _pinSession.active = false;
    _pinSession.cryptoKey = null;
    _sessionApiKey = '';
    _sessionEndpointBinding = '';
    try {
      toastHTML(`${SVG_LOCK} Session locked${reason ? ' (' + reason + ')' : ''} — re-enter your PIN to use the API key`, 4500);
    } catch {}
    // If Settings is open, refresh the General tab so it reflects the locked state
    // (matches the existing pattern used after Set/Remove PIN).
    try {
      const settingsPanel = document.querySelector('.ms2-settings-v2');
      const genPanel = settingsPanel?.querySelector('[data-panel="general"]');
      if (settingsPanel && genPanel && typeof _buildGeneralTab === 'function') {
        const _modelGroupMap = new Map();
        for (const m of MODELS) {
          const g = m.group || 'Other';
          if (!_modelGroupMap.has(g)) _modelGroupMap.set(g, []);
          _modelGroupMap.get(g).push(m);
        }
        const mOpts = [..._modelGroupMap.entries()].map(([gN, ms]) =>
          `<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`
        ).join('');
        const tmp = document.createElement('div');
        tmp.innerHTML = _buildGeneralTab('general', mOpts);
        genPanel.replaceWith(tmp.firstElementChild);
        _rewireGeneralTab(settingsPanel);
      }
    } catch {}
  }

  // Periodic idle check — cheap (every 30s), avoids per-event timer churn.
  function _pinStartIdleWatcher() {
    if (_pinIdleInterval) return;
    _pinIdleInterval = setInterval(() => {
      const idleMin = _pinGetIdleMin();
      if (idleMin <= 0 || !_pinSession.active) return;
      if (Date.now() - _pinLastActivity >= idleMin * 60 * 1000) {
        _pinLockNow('inactive ' + idleMin + 'm');
      }
    }, 30000);
  }

  // Track user activity via lightweight, passive listeners — just a timestamp
  // write, no per-event work. Also re-checks immediately when the tab regains
  // focus, since background-tab timers can be throttled by the browser.
  try {
    const _idleEvents = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'wheel', 'scroll'];
    for (const ev of _idleEvents) {
      document.addEventListener(ev, _pinTouchActivity, { passive: true, capture: true });
    }
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState !== 'visible') return;
      const idleMin = _pinGetIdleMin();
      if (idleMin > 0 && _pinSession.active && Date.now() - _pinLastActivity >= idleMin * 60 * 1000) {
        _pinLockNow('inactive ' + idleMin + 'm');
      }
      _pinTouchActivity();
    });
  } catch {}

  _pinStartIdleWatcher();

  // ─── INFO POPOVER SYSTEM ──────────────────────────────────────────────────
  // Renders a (!) button that opens a positioned popover with rich explanations.
  // Used throughout all Settings tabs to give users plain-language docs.

  let _activePopover = null;

  function _makeInfoBtn(id) {
    return `<button class="jv5-info-btn" data-info-id="${id}" title="Learn more">!</button>`;
  }

  // Popover content registry — all (!) explanations live here
  const _INFO_CONTENT = {

    'api-key-security': {
      title: `${/*SVG_LOCK — inlined small*/ '🔒'} API Key Storage`,
      tags: [{ text: 'Security', cls: 'sec' }],
      body: `
        <p>Your API key is the password to your AI account. By default it's saved in Tampermonkey's storage — which means <strong>other installed userscripts on this site could read it</strong> if they also have storage access.</p>
        <hr class="jv5-info-divider">
        <p><strong>PIN Protection (recommended)</strong> — When you set a PIN, your key is encrypted with <strong>AES-256-GCM</strong> before being saved. The encryption key is derived from your PIN using <strong>PBKDF2 at 310,000 rounds</strong> — the same standard used by password managers. The PIN itself is never stored anywhere. Without it, the saved data is useless ciphertext.</p>
        <p>You'll be asked to enter your PIN once per browser session.</p>
        <hr class="jv5-info-divider">
        <p>If you don't set a PIN, the key is stored as plain text — still protected by browser extension isolation, but readable by other scripts.</p>`,
    },

    'pin-idle-lock': {
      title: `${'🔒'} Auto-Lock After Inactivity`,
      tags: [{ text: 'Security', cls: 'sec' }],
      body: `
        <p>Normally, once you enter your PIN your API key stays decrypted in memory for the rest of the browser session — even if you walk away from the tab.</p>
        <hr class="jv5-info-divider">
        <p><strong>Auto-lock</strong> forgets the decrypted key after the chosen number of minutes with no mouse, keyboard, or touch activity. After it locks, you'll need to re-enter your PIN before the script can use your key again.</p>
        <p>This shrinks the window in which an unlocked key sits in memory — useful on shared or unattended machines. It has no effect on the encrypted copy stored on disk, which always requires your PIN.</p>
        <hr class="jv5-info-divider">
        <p>Set to <strong>Off</strong> (default) to keep the previous "unlock once per session" behavior.</p>`,
    },

    'api-provider': {
      title: '🌐 API Provider',
      tags: [{ text: 'Setup', cls: '' }],
      body: `
        <p>This is where your AI requests get sent. Each provider has different models, pricing, and free tiers.</p>
        <p><strong>OpenRouter</strong> — One key for every model (GPT-4o, Claude, Gemini, Llama, etc). Many <code>:free</code> models. Best starting point.</p>
        <p><strong>Groq</strong> — Extremely fast inference on Llama 4 / Qwen 3. Generous free tier. Great for quick replies.</p>
        <p><strong>Anthropic</strong> — Claude Opus / Sonnet / Haiku directly. Requires its own message format — JV5 handles this automatically.</p>
        <p><strong>Custom URL</strong> — Point at any OpenAI-compatible proxy (LiteRouter, Ollama, self-hosted, etc).</p>`,
    },

    'auth-mode': {
      title: '🔑 Auth Header Format',
      tags: [{ text: 'Setup', cls: '' }],
      body: `
        <p>Controls how your API key is sent to the provider in the HTTP request header.</p>
        <p><strong>Auto-detect</strong> — Uses <code>Bearer &lt;key&gt;</code> for everything. Works with OpenRouter, OpenAI, Groq, Mistral, and most proxies. <em>Use this unless you have a specific reason not to.</em></p>
        <p><strong>x-api-key</strong> — Required by native Anthropic API. JV5 sets this automatically when you pick the Anthropic provider.</p>
        <p><strong>Key only (raw)</strong> — Sends just the key with no prefix. Used by some niche self-hosted setups.</p>`,
    },

    'model-select': {
      title: '🧠 Model Selection',
      tags: [{ text: 'Setup', cls: '' }],
      body: `
        <p>The AI model that processes your prompts. Different models have different writing styles, context windows, and costs.</p>
        <p><strong>Free models</strong> — On OpenRouter, models ending in <code>:free</code> have zero cost but may be slower or have usage caps.</p>
        <p><strong>Context window</strong> — Longer context = the AI sees more of your chat history. Models like <code>gemma-3-27b</code> offer 128K tokens free.</p>
        <p><strong>Custom model ID</strong> — Type any valid model string your provider supports, e.g. <code>meta-llama/llama-4-maverick:free</code>.</p>`,
    },

    'relay-url': {
      title: '📡 Community Chat Relay',
      tags: [{ text: 'Privacy', cls: 'sec' }, { text: 'Advanced', cls: 'warn' }],
      body: `
        <p>Community Chat uses <strong>ntfy.sh</strong> as a free relay to exchange encrypted messages between JV5 users. Messages are encrypted with AES-256-GCM before leaving your browser — ntfy.sh only sees ciphertext.</p>
        <p><strong>Why change this?</strong> — If you run your own <a href="https://ntfy.sh/docs/install/" target="_blank">ntfy instance</a> for full privacy, enter its URL here. Self-hosted = only your server sees the traffic.</p>
        <p><strong>Access token (optional)</strong> — If your self-hosted ntfy server requires auth to read/write topics, add an access token below the URL. It's sent as <code>Authorization: Bearer …</code> and is <strong>never</strong> sent to the default ntfy.sh.</p>
        <p><strong>Security note:</strong> Private/local addresses (192.168.x, 127.x, localhost) are blocked to prevent accidental misuse.</p>`,
    },

    'advanced-crypto': {
      title: '🔐 Advanced Crypto',
      tags: [{ text: 'Security', cls: 'sec' }],
      body: `
        <p>These toggles control the encryption stack for Community Chat. All three are on by default and work together.</p>
        <p><strong>Double Ratchet FS</strong> — Each peer gets their own encryption chain. Old message keys are deleted after use. Even if someone captures all traffic and later gets your PSK, they cannot decrypt past messages.</p>
        <p><strong>PBKDF2-SHA-512 KDF</strong> — Uses PBKDF2-SHA-512 at 350,000 iterations + an HKDF domain-separation pass for room password key derivation. Makes brute-forcing captured ciphertext ~2.5× more expensive than the default PBKDF2-SHA-256 path.</p>
        <p><strong>HMAC-SHA-512 Signatures</strong> — Admin commands are signed with HMAC-SHA-512 + HKDF domain separation instead of plain HMAC-SHA-256. Captured admin signatures can't be reused in other contexts.</p>
        <p>Only disable these if other users on older JV5 versions can't decrypt your messages.</p>`,
    },

    'community-passphrase': {
      title: '🔑 Community Passphrase',
      tags: [{ text: 'Privacy', cls: 'sec' }],
      body: `
        <p>By default, all JV5 users share a common encryption baseline (the PSK) so everyone can read the global chat room.</p>
        <p>Setting a <strong>custom passphrase</strong> creates a <em>private sub-group</em>: only users who set the exact same passphrase can read your messages. Everyone else sees encrypted data they can't decrypt.</p>
        <p>Your passphrase is hashed with SHA-256 — the raw text is never stored. Clearing the field reverts to the shared PSK seamlessly.</p>
        <p><strong>Tip:</strong> Share your passphrase via a private channel (Discord DM, Signal) — never in the JV5 chat itself.</p>`,
    },

    'smart-reply': {
      title: '💬 Smart Reply',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p>Smart Reply generates a continuation or response to the latest AI message using your configured model and tone.</p>
        <p><strong>Tone</strong> — Sets the emotional register of the reply (Playful, Assertive, Tender, etc). Overrides the preset's default for this one reply.</p>
        <p><strong>Custom Instruction</strong> — A one-line director's note: "resist but secretly enjoy it", "be distracted", "push back". Appended to the prompt — not visible to the AI as character dialogue.</p>
        <p><strong>Scene Context</strong> — Your current-situation note (set in the Context tab) is also injected here to keep the AI grounded in where things are.</p>`,
    },

    'scene-context': {
      title: '📄 Scene Context',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p>A short note about the <em>current situation</em> — where characters are, the mood, recent events, unresolved tension. Injected into every Reply and Shorten prompt.</p>
        <p><strong>Is this the same as JanitorAI's memory box?</strong> No. JanitorAI's memory is for static backstory and personality. Scene Context is for dynamic, real-time situation tracking that changes as the story progresses.</p>
        <p><strong>Send to JanitorAI's AI</strong> — When enabled, Scene Context is also injected into JanitorAI's own generation on every message, not just JV5's Smart Reply.</p>
        <p><strong>Auto-generate</strong> — JV5 can write a new Scene Context every N messages by reading the visible chat and summarising the situation automatically.</p>`,
    },

    'persona-library': {
      title: '👤 Persona Library',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p>Save named character descriptions — how they speak, their mood, quirks, tone. Hit <strong>Use</strong> to instantly load one into the Scene Context box.</p>
        <p>Useful for: switching between different AI character setups quickly, saving your own player persona for reuse across chats, or keeping a library of narrative modes (serious mode, comedic mode, etc).</p>
        <p><strong>Export / Import</strong> — Backs up your full library as a JSON file. Import merges with existing personas, not replace.</p>`,
    },

    'styles-presets': {
      title: '🎨 Style Presets',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p>Presets save a character's full voice configuration: tone, custom instruction, persona note, and any Prompt Modules. The active preset is auto-injected when you open Smart Reply.</p>
        <p>Think of a preset as a "saved character mode" — one for your main character, one for a side character, one for a specific story arc.</p>
        <p>Use <strong>Export ↑</strong> to back up your presets as JSON, and <strong>Import ↓</strong> to restore them or share with others.</p>`,
    },

    'advanced-prompting': {
      title: '⚡ Advanced Prompting (AP)',
      tags: [{ text: 'Feature', cls: '' }, { text: 'Advanced', cls: 'warn' }],
      body: `
        <p>When enabled, AP intercepts every JanitorAI generation and replaces the <code>llm_prompt</code> field with your active preset's content. Your proxy/jailbreak is left completely untouched — only the character prompt is swapped.</p>
        <p><strong>Prompt Modules</strong> — Reusable blocks of text (character description, writing rules, tone notes) that combine to form the full prompt. Drag to reorder. Toggle individual modules on/off without deleting them.</p>
        <p><strong>Forbidden Words</strong> — Injected into every generation. Bypasses JanitorAI's 10-word ban limit.</p>
        <p><strong>Thinking</strong> — Appends reasoning instructions for models that support extended thinking (Claude 3.5+, o1, Gemini 2.0+).</p>`,
    },

    'auto-notify': {
      title: '🔔 AI Message Notification',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p>When ON, a small toast notification appears at the top of the screen when a new AI message arrives — useful if you've scrolled away or have another tab open.</p>
        <p>This is <strong>non-intrusive</strong>: it never auto-opens any modal or interrupts your typing. It simply lets you know a reply landed without you having to watch the screen.</p>
        <p>The notification auto-dismisses after 5 seconds.</p>`,
    },

    'summarise': {
      title: '📝 Summarise',
      tags: [{ text: 'Feature', cls: '' }],
      body: `
        <p><strong>FAB → Summarise</strong> reads <em>all</em> visible messages in the current chat and writes a narrative memory entry — covering what happened across the whole story so far.</p>
        <p><strong>Context tab → Generate</strong> reads only the <em>currently visible</em> messages and writes a current-situation note (where you are right now, the mood, recent events). Use this more frequently to keep Scene Context fresh.</p>
        <p>Both tools save their output to Scene Context and optionally to your character memory store, so future replies stay consistent with the story's history.</p>`,
    },

    'p2p-e2e': {
      title: '🔒 End-to-End Encryption',
      tags: [{ text: 'Security', cls: 'sec' }],
      body: `
        <p>Every Community Chat message is encrypted <strong>before it leaves your browser</strong>. The relay (ntfy.sh) only ever sees opaque ciphertext — it cannot read your messages even if it wanted to.</p>
        <p><strong>PSK layer</strong> — All messages are wrapped in AES-256-GCM using a key derived from your device + optional passphrase. This is always on.</p>
        <p><strong>Double Ratchet</strong> — For peers you've connected with, each message uses a fresh key derived from an ECDH handshake chain. Past messages stay secret even if your current key is somehow exposed.</p>
        <p><strong>Custom passphrase</strong> — Adds a second encryption layer. Only users who know the passphrase can read your messages.</p>`,
    },
  };

  function _showInfoPopover(btn, contentId) {
    const wasThisBtn = (_activePopover !== null && btn.classList.contains('active'));

    // Close any open popover first
    if (_activePopover) {
      _activePopover.remove();
      _activePopover = null;
      document.querySelectorAll('.jv5-info-btn.active').forEach(b => b.classList.remove('active'));
    }

    // Toggle: same button tapped while already open → just close, don't reopen
    if (wasThisBtn) return;

    const content = _INFO_CONTENT[contentId];
    if (!content) return;

    const pop = document.createElement('div');
    pop.className = 'jv5-info-popover';
    pop.style.cssText = 'position:fixed;z-index:2147483600;';

    const tagsHTML = (content.tags || []).map(t =>
      `<span class="jv5-info-tag ${t.cls || ''}">${t.text}</span>`
    ).join(' ');

    pop.innerHTML = `
      <h4>${content.title}</h4>
      ${tagsHTML ? `<div style="margin-bottom:8px;">${tagsHTML}</div>` : ''}
      ${content.body}
    `;

    document.body.appendChild(pop);

    // Use getBoundingClientRect + fixed positioning — works inside scrolled stacked modals
    const bRect = btn.getBoundingClientRect();
    const pW    = pop.offsetWidth  || 280;
    const pH    = pop.offsetHeight || 200;
    const vW    = window.innerWidth;
    const vH    = window.innerHeight;

    let left = bRect.left + bRect.width / 2 - pW / 2;
    let top  = bRect.bottom + 8;
    if (left + pW > vW - 8) left = vW - pW - 8;
    if (left < 8) left = 8;
    if (top + pH > vH - 8) top = bRect.top - pH - 8;
    if (top < 8) top = 8;

    pop.style.left = left + 'px';
    pop.style.top  = top  + 'px';

    _activePopover = pop;
    btn.classList.add('active');

    // Dismiss on outside pointer — 150 ms delay avoids catching the opening touch
    const _dismiss = (e) => {
      if (pop.contains(e.target) || btn.contains(e.target) || e.target === btn) return;
      pop.remove();
      if (_activePopover === pop) _activePopover = null;
      btn.classList.remove('active');
      document.removeEventListener('pointerdown', _dismiss, true);
    };
    setTimeout(() => document.addEventListener('pointerdown', _dismiss, true), 150);
  }

  // Wire up all (!) buttons via event delegation on the document
  // so they work even inside dynamically rendered modals
  document.addEventListener('click', e => {
    const btn = e.target.closest('.jv5-info-btn');
    if (!btn) return;
    e.stopPropagation();
    const id = btn.dataset.infoId;
    if (id) _showInfoPopover(btn, id);
  }, true);

  // ─── GM FETCH WRAPPER ─────────────────────────────────────────────────────
  /**
   * GM_xmlhttpRequest wrapped as a Fetch-compatible Promise.
   * Supports AbortSignal, parses response headers into a `get()` accessor,
   * and exposes `.text()` / `.json()` on the resolved value — matching the
   * native `fetch` API surface used throughout the script.
   *
   * @param {string}  url     - Full URL to request.
   * @param {object}  options - Subset of the Fetch `init` object:
   *   `method`, `headers`, `body`, `signal` (AbortSignal).
   * @returns {Promise<{ok:boolean, status:number, headers:{get:function}, text:function, json:function}>}
   */
  function gmFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
      const signal = options.signal;
      if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; }
      let req;
      try {
        req = GM_xmlhttpRequest({
          method:  options.method || 'GET',
          url:     url,
          headers: options.headers || {},
          data:    options.body   || null,
          onload(r) {
            const ok = r.status >= 200 && r.status < 300;
            // Parse raw response headers string into a lookup map
            const _hdrs = {};
            (r.responseHeaders || '').split(/\r?\n/).forEach(line => {
              const idx = line.indexOf(':');
              if (idx > 0) {
                const k = line.slice(0, idx).trim().toLowerCase();
                _hdrs[k] = line.slice(idx + 1).trim();
              }
            });
            resolve({
              ok,
              status:  r.status,
              headers: { get: name => _hdrs[name.toLowerCase()] ?? null },
              text:    () => Promise.resolve(r.responseText),
              json()   {
                try { return Promise.resolve(JSON.parse(r.responseText)); }
                catch (e) { return Promise.reject(e); }
              },
            });
          },
          onerror()   { reject(new TypeError('Failed to fetch (network error — check API key and endpoint)')); },
          onabort()   { reject(new DOMException('Aborted', 'AbortError')); },
          ontimeout() { reject(new TypeError('Request timed out')); },
        });
      } catch (e) {
        reject(new TypeError('GM_xmlhttpRequest failed: ' + e.message));
        return;
      }
      
      if (signal) {
        signal.addEventListener('abort', () => { try { req?.abort(); } catch {  } });
      }
    });
  }  // close gmFetch


  // ─── ROBUST SELF-HEALING SELECTOR ENGINE v2 (Long-term Resilience) ─────────
  // Multiple fallbacks + heuristic probing + MutationObserver self-repair
  // + remote updatable map. Survives JanitorAI React/Virtuoso DOM changes for years.
  // "Connects to actual source" by deeply inspecting rendered React Fiber + DOM signals.

  /**
   * Self-healing CSS selector engine.
   *
   * Resolves element selectors through a three-tier strategy:
   *   1. **Primary** — best-known selectors, checked on every call.
   *   2. **Fallbacks** — a ranked list of alternatives per key.
   *   3. **Heuristic probe** — inspects rendered React Fiber props + DOM
   *      signals when both primary and fallbacks fail; auto-promotes winners.
   *
   * A MutationObserver (`startSelfHealing`) watches for structural DOM changes
   * and re-probes periodically so the engine adapts to JanitorAI updates
   * without requiring a script update. Selector discoveries are persisted to
   * GM storage and can optionally be fetched from a remote JSON endpoint.
   */
  class SelectorEngine {
    constructor() {
      this.primary = {
        // v5.6.6 — JanitorAI removed data-testid="virtuoso-item-list" from the
        // Virtuoso list parent. New confirmed selector: div > div[data-index]
        // (7 matches on live chat, confirmed by DOM Detective 2026-06-06).
        virtuosoItemList: 'div > div[data-index]',
        virtuosoScroller: '[data-testid="virtuoso-scroller"]',
        virtuosoItemListParent: '[class*="_messagesMain_"]',
        messageBody: '[class*="_messageBody_"]',
        botIcon: '[class*="_nameIcon_"]',
        messagesMain: '[class*="_messagesMain_"]',
        authorRoleAssistant: '[data-message-author-role="assistant"]',
        authorRoleUser: '[data-message-author-role="user"]',
      };
      this.fallbacks = {
        virtuosoItemList: [
          '[data-testid="virtuoso-item-list"] > div[data-index]', // pre-2026 (keep for revert)
          '[data-testid*="virtuoso"] [data-index]',
          '[class*="_messagesMain_"] div[data-index]',
          'div[data-index][class*="message"]',
          '[class*="virtuoso-item"] > div',
          'div[role="listitem"][data-index]'
        ],
        messageBody: ['[class*="messageBody"]', '[class*="MessageBody"]', 'div[class*="message"] p'],
        botIcon: ['[class*="nameIcon"]', '[class*="botIcon"]', 'img[alt*="bot"]', '[data-bot="true"]'],
        authorRoleAssistant: ['[data-message-author-role="assistant"]', '[data-role="assistant"]', '[class*="assistant"]'],
      };
      this.hits = new Map(); // learning which strategies work
      this.lastProbe = 0;
      this.observer = null;
      this.remoteUrl = gget('jv5_selector_remote_url', ''); // user can set their own JSON endpoint
      this.versionKey = 'jv5_selector_version';
    }

    get(key) {
      // 1. Primary (fast path)
      let sel = this.primary[key];
      if (sel && document.querySelector(sel)) {
        this._recordHit(key, 'primary');
        return sel;
      }

      // 2. Fallbacks
      const fbs = this.fallbacks[key] || [];
      for (const fb of fbs) {
        if (document.querySelector(fb)) {
          this._recordHit(key, 'fallback');
          return fb;
        }
      }

      // 3. Heuristic probe for critical keys (survives major refactors)
      if (['virtuosoItemList', 'messageBody', 'botIcon'].includes(key) && Date.now() - this.lastProbe > 30000) {
        const found = this._heuristicProbe(key);
        if (found) {
          this._recordHit(key, 'heuristic');
          // Optionally auto-promote to primary for this session
          this.primary[key] = found;
          return found;
        }
        this.lastProbe = Date.now();
      }

      // 4. Last resort: return primary (will fail gracefully, triggering debug)
      return this.primary[key] || '';
    }

    _recordHit(key, strategy) {
      const k = `${key}:${strategy}`;
      this.hits.set(k, (this.hits.get(k) || 0) + 1);
      // Persist top strategies occasionally
      if (Math.random() < 0.05) this._persistLearning();
    }

    _persistLearning() {
      try {
        const data = {};
        for (const [k, v] of this.hits) data[k] = v;
        gset('jv5_selector_learning', JSON.stringify(data));
      } catch {}
    }

    _heuristicProbe(key) {
      // Score potential containers by multiple signals from the actual rendered React app
      const candidates = [];
      const allDivs = document.querySelectorAll('div[data-index], div[class*="message"], [role="listitem"]');
      
      for (const el of allDivs) {
        let score = 0;
        const text = (el.textContent || '').trim();
        
        // Signal 1: Has data-index (Virtuoso)
        if (el.hasAttribute('data-index')) score += 40;
        
        // Signal 2: Fiber role detection (deep React source inspection)
        const fiberRole = this._quickFiberRole(el);
        if (fiberRole === 'assistant' || fiberRole === 'user') score += 35;
        
        // Signal 3: Contains bot icon or name icon
        if (el.querySelector('[class*="_nameIcon_"], [class*="nameIcon"], img[alt*="character"]')) score += 25;
        
        // Signal 4: Reasonable message length
        if (text.length > 20 && text.length < 4000) score += 15;
        
        // Signal 5: Has role or data-message-author-role
        if (el.querySelector('[data-message-author-role]') || el.getAttribute('data-message-author-role')) score += 20;
        
        if (score > 50) candidates.push({ el, score, sel: this._makeSelector(el) });
      }
      
      if (candidates.length === 0) return null;
      candidates.sort((a, b) => b.score - a.score);
      return candidates[0].sel; // best guess selector
    }

    _quickFiberRole(node) {
      try {
        let fiber = node;
        for (let i = 0; i < 8 && fiber; i++) { // limited depth for perf
          const key = Object.keys(fiber).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
          if (key) {
            let f = fiber[key];
            while (f && i++ < 12) {
              const props = f.memoizedProps || f.pendingProps;
              if (props?.role) return props.role;
              if (props?.message?.role) return props.message.role;
              f = f.return;
            }
          }
          fiber = fiber.parentNode;
        }
      } catch {}
      return null;
    }

    _makeSelector(el) {
      // Generate a reasonably stable CSS selector from the winning element
      if (el.id) return `#${el.id}`;
      if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
      if (el.hasAttribute('data-index')) return `div[data-index="${el.getAttribute('data-index')}"]`;
      const cls = Array.from(el.classList).find(c => c.includes('message') || c.includes('item'));
      return cls ? `div.${CSS.escape(cls)}` : 'div[data-index]';
    }

    startSelfHealing() {
      if (this.observer) return;
      this.observer = new MutationObserver((mutations) => {
        // Only react to significant structural changes in chat area
        for (const m of mutations) {
          if (m.addedNodes.length > 0) {
            const hasVirtuoso = document.querySelector('[data-testid*="virtuoso"]');
            if (hasVirtuoso && Date.now() - this.lastProbe > 45000) {
              // Re-probe silently
              this._heuristicProbe('virtuosoItemList');
              this.lastProbe = Date.now();
            }
            break;
          }
        }
      });
      this.observer.observe(document.body, { childList: true, subtree: true });
      
      _devLog('[JanitorV5] Self-healing MutationObserver active — script will adapt to JanitorAI changes');
    }  // properly close startSelfHealing

    loadRemote() {
      if (!this.remoteUrl) return;
      if (Date.now() - (this._lastRemoteFetch || 0) < 3600000) return; // 1-hour cache
      this._lastRemoteFetch = Date.now();
      try {
        GM_xmlhttpRequest({
          method: 'GET', url: this.remoteUrl, timeout: 8000,
          onload: (r) => {
            try {
              const map = JSON.parse(r.responseText);
              if (map && typeof map === 'object') {
                // SECURITY: Only accept known keys with safe string values.
                // Prevents a compromised remote URL from injecting arbitrary selectors.
                const ALLOWED_SELECTOR_KEYS = new Set([
                  'virtuosoItemList','virtuosoScroller','virtuosoItemListParent',
                  'messageBody','botIcon','messagesMain',
                  'authorRoleAssistant','authorRoleUser'
                ]);
                let imported = 0;
                for (const [k, v] of Object.entries(map)) {
                  if (ALLOWED_SELECTOR_KEYS.has(k) && typeof v === 'string' && v.length < 200
                      && !v.includes('<') && !v.includes('javascript')) {
                    this.primary[k] = v;
                    imported++;
                  }
                }
                gset(this.versionKey, Date.now());
                _devLog('[JanitorV5] Remote selector map loaded:', imported, 'validated keys');
              }
            } catch (e) { _devWarn('[JanitorV5] loadRemote parse error:', e); }
          },
          onerror: () => _devWarn('[JanitorV5] loadRemote: fetch failed for URL'),
        });
      } catch (e) { _devWarn('[JanitorV5] loadRemote error:', e); }
    }

    stopSelfHealing() {
      if (this.observer) { this.observer.disconnect(); this.observer = null; }
    }

  }  // properly close SelectorEngine

  // ─── INTERNAL EVENT BUS (Correct Module Scope) ─────────────────────────────
  /**
   * Minimal publish-subscribe event bus used for loose coupling between
   * independent subsystems (network circuit breaker, WebRTC status, etc.).
   *
   * Exposed on `unsafeWindow.jv5Bus` for external tooling and diagnostics.
   */
  class EventBus {
    constructor() { this.listeners = new Map(); }
    on(event, fn) {
      if (!this.listeners.has(event)) this.listeners.set(event, new Set());
      this.listeners.get(event).add(fn);
    }
    off(event, fn) {
      this.listeners.get(event)?.delete(fn);
    }
    emit(event, data) {
      this.listeners.get(event)?.forEach(fn => {
        try { fn(data); } catch (e) { _devWarn('[JanitorV5 EventBus]', event, e); }
      });
    }
  }  // close EventBus class

  const bus = new EventBus();
  // SECURITY: Only expose jv5Bus to page context in developer mode.
  // Without the gate, any page-context script can subscribe to events like
  // 'webrtcEnabled' to learn when a user enters a character room.
  try {
    const _busDevMode = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
    if (_busDevMode) unsafeWindow.jv5Bus = bus;
  } catch {}

  // ─── DEV-MODE LOGGER ──────────────────────────────────────────────────────
  // All verbose operational output is gated behind jv5_dev_mode = true.
  // This prevents page-context scripts (or a malicious extension riding the
  // same origin) from overriding console.log to harvest internal state such
  // as peer IDs, ratchet events, message counts, and WebRTC topology.
  // Enable via browser console: GM_setValue('jv5_dev_mode', true) then reload.
  const _DEV_MODE = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
  // Suppress lint: intentional no-op in production
  // eslint-disable-next-line no-console
  const _devLog  = _DEV_MODE ? (...a) => console.log(...a)  : () => {};
  const _devWarn = _DEV_MODE ? (...a) => console.warn(...a) : () => {};

  // ─── ADVANCED NETWORK INTERCEPTOR (Multi-Layer + Resilient) ────────────────
  /**
   * Dual-layer network interceptor that patches both `window.fetch` and
   * `window.XMLHttpRequest` in the page context.
   *
   * Implements a **circuit breaker**: after `maxFailures` consecutive errors
   * the breaker opens for `circuitTimeoutMs` ms and rejects new generate
   * requests immediately, preventing cascading timeouts during an outage.
   *
   * The XHR layer intentionally no longer stores request payloads in GM
   * storage (payload privacy improvement from v5.5.9).
   */
  class NetworkInterceptor {
    constructor() {
      this.originalFetch = null;
      this.originalXHR = null;
      this.circuitOpenUntil = 0;
      this.failureCount = 0;
      this.maxFailures = 5;
      this.circuitTimeoutMs = 30000;
    }

    init() {
      this._patchFetch();
      this._patchXHR();
      _devLog('[JanitorV5] Multi-layer NetworkInterceptor active (fetch + XHR)');
    }

    _shouldCircuitBreak() {
      return Date.now() < this.circuitOpenUntil;
    }

    _recordFailure() {
      this.failureCount++;
      if (this.failureCount >= this.maxFailures) {
        this.circuitOpenUntil = Date.now() + this.circuitTimeoutMs;
        this.failureCount = 0;
        bus.emit('networkCircuitOpen', { until: this.circuitOpenUntil });
      }
    }

    _recordSuccess() {
      this.failureCount = Math.max(0, this.failureCount - 1);
      if (this.circuitOpenUntil && Date.now() > this.circuitOpenUntil) {
        this.circuitOpenUntil = 0;
        bus.emit('networkCircuitClosed');
      }
    }

    _patchFetch() {
      if (typeof unsafeWindow === 'undefined' || this.originalFetch) return;
      this.originalFetch = unsafeWindow.fetch;
      const self = this;

      unsafeWindow.fetch = async function (...args) {
        if (self._shouldCircuitBreak()) {
          return Promise.reject(new Error('Network circuit breaker open'));
        }
        try {
          const response = await self.originalFetch.apply(this, args);
          self._recordSuccess();
          return response;
        } catch (err) {
          self._recordFailure();
          throw err;
        }
      };
    }

    _patchXHR() {
      if (typeof unsafeWindow === 'undefined' || this.originalXHR) return;
      this.originalXHR = unsafeWindow.XMLHttpRequest;
      const self = this;

      unsafeWindow.XMLHttpRequest = function () {
        const xhr = new self.originalXHR();
        const originalOpen = xhr.open;
        const originalSend = xhr.send;

        xhr.open = function (method, url, ...rest) {
          this._jv5Url = url;
          return originalOpen.apply(this, [method, url, ...rest]);
        };

        xhr.send = function (body) {
          if (self._shouldCircuitBreak() && this._jv5Url && this._jv5Url.includes('generate')) {
            setTimeout(() => {
              if (this.onerror) this.onerror(new Event('error'));
            }, 0);
            return;
          }

          // PRIVACY: Full chat payload is no longer stored in GM storage.
          // Removed jv4_lastGeneratePayload write to prevent full message context
          // from being accessible to other userscripts via GM_getValue.

          const origOnLoad = this.onload;
          this.onload = function (...loadArgs) {
            self._recordSuccess();
            if (origOnLoad) return origOnLoad.apply(this, loadArgs);
          };

          const origOnError = this.onerror;
          this.onerror = function (...errArgs) {
            self._recordFailure();
            if (origOnError) return origOnError.apply(this, errArgs);
          };

          return originalSend.apply(this, [body]);
        };

        return xhr;
      };
    }

    restore() {
      if (this.originalFetch) {
        try { unsafeWindow.fetch = this.originalFetch; } catch {}
        this.originalFetch = null;
      }
      if (this.originalXHR) {
        try { unsafeWindow.XMLHttpRequest = this.originalXHR; } catch {}
        this.originalXHR = null;
      }
    }

  }  // close NetworkInterceptor class

  const netInterceptor = new NetworkInterceptor();

  // ─── SAFE MESSAGE MERGE ───────────────────────────────────────────────────
  // Allowlist-based field copy from decrypted payloads into msg objects.
  // Prevents prototype pollution: a maliciously crafted payload containing
  // __proto__, constructor, or toString would bypass Object.assign protections.
  const _MSG_ALLOWED_FIELDS = new Set([
    'v','peer','nick','text','ts','room','msgId','type','replyTo',
    'ratchet','peerId','encrypted','adminSig','publicKey','ratchetFor',
    'reactionTo','emoji','n','iv','ciphertext','ratchetReply',
  ]);
  function _safeMergeMsg(target, src) {
    if (!src || typeof src !== 'object' || Array.isArray(src)) return;
    for (const key of _MSG_ALLOWED_FIELDS) {
      if (Object.prototype.hasOwnProperty.call(src, key)) {
        target[key] = src[key];
      }
    }
  }

  // ─── P2P RELAY FAILOVER ────────────────────────────────────────
  const P2P_RELAYS = ['https://ntfy.sh'];

  // SSRF block-list — matches private/loopback/link-local addresses that must
  // never be used as relay or verified-user URLs. The info popover documents
  // this protection; this regex is the actual enforcement.
  // Covers: loopback (127.x, ::1), private (10.x, 172.16-31.x, 192.168.x),
  // SSRF block-list — matches private/loopback/link-local addresses that must
  // never be used as relay or verified-user URLs. The info popover documents
  // this protection; this regex is the actual enforcement.
  // Covers: loopback (127.x, ::1), private (10.x, 172.16-31.x, 192.168.x),
  // link-local (169.254.x — AWS/GCP metadata endpoint lives here),
  // CGNAT (100.64-127.x), unspecified (0.0.0.0), ULA IPv6 (fc00::/7 → fc/fd prefix),
  // IPv6-mapped loopback (::ffff:127.x), and bare localhost.
  const _SSRF_BLOCK_RE = new RegExp(
    '^https?:\\/\\/(localhost|127\\.|0\\.0\\.0\\.0|10\\.\\d+|172\\.(1[6-9]|2\\d|3[01])\\.|192\\.168\\.|169\\.254\\.|100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.|::1|\\[::1\\]|\\[::ffff:(127\\.|[0:]+:1)\\]|f[cd][0-9a-f]{2}:)',
    'i'
  );

  // Runtime relay getter — reads user-configured URL from storage, falls back to default.
  // Validates: must start with https?, must not target private/loopback/link-local hosts.
  function _p2pGetRelay() {
    try {
      const stored = GM_getValue(P2P_GM_RELAY, '').trim();
      if (stored && /^https?:\/\/.+/.test(stored) && !_SSRF_BLOCK_RE.test(stored)) {
        return stored.replace(/\/$/, '');
      }
    } catch {}
    return P2P_RELAYS[0];
  }

  // ─── OPTIONAL RELAY AUTHENTICATION (self-hosted ntfy access tokens) ────────
  // ntfy.sh's public instance has no per-topic ACLs, but a self-hosted ntfy
  // server can require an access token (ntfy user/token auth) before it will
  // serve a topic. If the user has pointed Community Chat at their own relay
  // (P2P_GM_RELAY != default) and saved a token, we attach it as a Bearer
  // Authorization header on every relay request.
  //
  // Safety rails:
  //  • The token is NEVER sent to the default public ntfy.sh instance, even
  //    if one happens to be stored — it only applies when a custom relay URL
  //    is configured. This prevents a stale token from leaking to the public
  //    relay if the user switches back to the default.
  //  • The relay URL itself is already restricted to https:// and non-private
  //    hosts by _p2pGetRelay()'s SSRF guard, so the token always travels over
  //    TLS to a host the user explicitly opted into.
  //  • This does NOT replace E2E encryption — it only restricts who can read
  //    the (still end-to-end encrypted) ciphertext stream at the relay layer.
  const P2P_GM_RELAY_TOKEN = 'jv4_p2p_relay_token';

  function _p2pGetRelayToken() {
    try { return GM_getValue(P2P_GM_RELAY_TOKEN, '').trim(); } catch { return ''; }
  }

  /**
   * Builds a headers object for a relay (ntfy) request, merging in an
   * `Authorization: Bearer <token>` header when the user has configured a
   * non-default relay AND saved an access token for it.
   * @param {Object} extra - Additional headers to merge in (e.g. Content-Type).
   * @returns {Object} Final headers object for GM_xmlhttpRequest.
   */
  function _p2pRelayHeaders(extra = {}) {
    const headers = { ...extra };
    try {
      const relay = _p2pGetRelay();
      const token = _p2pGetRelayToken();
      if (token && relay !== P2P_RELAYS[0]) {
        headers['Authorization'] = 'Bearer ' + token;
      }
    } catch {}
    return headers;
  }

  const P2P_RELAY        = P2P_RELAYS[0];

  // ─── POLL-INTERVAL JITTER ───────────────────────────────────────────────────
  // Long-poll reconnects and heartbeats normally fire on a perfectly fixed
  // cadence (e.g. exactly every 800ms / 30s). To a relay operator watching
  // request timing, that's a recognizable fingerprint distinguishing this
  // script's traffic from other ntfy clients. Adding small random jitter
  // (±~20%, floor 350ms) breaks that fixed cadence without meaningfully
  // affecting responsiveness.
  function _jitteredPollMs(baseMs) {
    const rand = crypto.getRandomValues(new Uint32Array(1))[0] / 0xFFFFFFFF;
    const jitter = 0.8 + rand * 0.4; // 0.8x – 1.2x
    return Math.max(350, Math.floor(baseMs * jitter));
  }
  // ─── TOPIC NAME HARDENING ──────────────────────────────────────────────────
  // Topic names are hashed (SHA-256, first 20 hex chars) so they're not
  // guessable by reading the source. Someone who reads the code still needs to
  // SHA-256 the seed strings to find the ntfy topics.
  // Char-room topics additionally incorporate the char ID so each room is isolated.
  const _topicSeed        = 'jv5-community-2026';
  const P2P_TOPIC_GLOBAL  = await (async () => {
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-global'));
    return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
  })();
  const P2P_TOPIC_TYPING  = await (async () => {
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-typing'));
    return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
  })();
  const P2P_TOPIC_HB      = await (async () => {
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-hb'));
    return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
  })();
  const P2P_TOPIC_SIGNAL  = await (async () => {
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-webrtc-signal'));
    return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
  })();
  // SECURITY FIX #9: Reports topic was the only topic NOT hashed, making it trivially
  // guessable by anyone reading the source. Now hashed consistently with all other topics.
  const P2P_TOPIC_REPORTS = await (async () => {
    const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-reports'));
    return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
  })();

  // Storage keys
  const P2P_GM_PEERID        = 'jv4_p2p_peerid';
  const P2P_GM_NICKNAME      = 'jv4_p2p_nickname';
  const P2P_GM_ROOM          = 'jv4_p2p_room';
  const P2P_GM_HISTORY       = 'jv4_p2p_history';
  const P2P_GM_BLOCKED       = 'jv4_p2p_blocked';
  const P2P_GM_PINNED        = 'jv4_p2p_pinned';
  const P2P_GM_ENABLED       = 'jv4_p2p_enabled';
  const P2P_GM_TIP_SEEN      = 'jv4_p2p_tip_seen';
  const P2P_GM_LAST_CHAR     = 'jv4_p2p_last_char';
  const P2P_GM_LAST_CHAR_NAME= 'jv4_p2p_last_char_name';
  const P2P_GM_RELAY         = 'jv4_p2p_relay';   // user-configurable ntfy relay base URL

  // Timing & limits
  const P2P_POLL_MS        = 800;
  const P2P_BACKOFF_MIN    = 800;
  const P2P_BACKOFF_MAX    = 30000;
  const P2P_BACKOFF_MULT   = 1.5;
  const P2P_RATE_LIMIT_MS  = 2000;
  const P2P_MAX_HISTORY    = 200;
  const P2P_HB_SEND_MS     = 30000;
  const P2P_HB_EXPIRE_MS   = 90000;
  const P2P_TYPING_TTL     = 3000;

  // Emoji sets
  const P2P_REACTION_EMOJIS = ['❤️','😂','😮','😢','😡','👍'];
  const P2P_CHAT_EMOJIS     = ['😊','😂','❤️','👍','🔥','😍','🎉','😎',
                                '🥺','😭','😤','🤔','✨','💀','👀','🤣'];

  // Admin: hash is SHA-256 of the admin password set by the room creator.
  // Not hard-coded — derive from a user-supplied password via _sha256().
  // This placeholder causes admin commands to be disabled until unlocked.
  const P2P_ADMIN_HASH = gget('jv4_p2p_admin_hash', '');

  // Optional remote verification URL (leave empty to disable)
  const P2P_VERIFIED_URL = gget('jv5_verified_url', '');


  // ─── UTILITY HELPERS ───────────────────────────────────────────────────────
  /**
   * Returns the URL of the healthiest available ntfy relay.
   *
   * Currently a stub that always returns the first (and only) entry in
   * `P2P_RELAYS`.  Intended extension point for active health-checking /
   * round-robin across multiple relay URLs.
   *
   * @returns {string} Base relay URL (no trailing slash).
   */
  function _getHealthyRelay() {
    return _p2pGetRelay();
  }

  // ─── STORAGE MIGRATION ─────────────────────────────────────────────────────
  function migrateStorage() {
    const currentVer = gget('jv5_storage_version', 0);
    if (currentVer < 1) {
      gset('jv5_storage_version', 1);
    }
  }

  try { migrateStorage(); } catch {}

  // ─── P2P END-TO-END ENCRYPTION LAYER (Phase 1 - Strong Security without Server) ─
  // Uses Web Crypto API (AES-GCM) + PBKDF2 for authenticated encryption.
  // Messages are encrypted client-side before hitting ntfy.sh.
  // Requires users to share a password out-of-band for a room (Discord, Signal, etc.).
  // This makes passive reading/injection on public ntfy topics useless without the password.
  // Replay protection via sequence numbers + timestamp window.
  // Recommended for char rooms. Global room stays unencrypted by default (with warning).

  // Padding helpers — used inside P2PCrypto to obscure message length from traffic analysis.
  // NOT exposed to unsafeWindow (no external access needed).
  function _padMessage(str, blockSize = 256) {
    const data = new TextEncoder().encode(str);
    // Reserve 2 bytes at the END for the original length — account for them in block math
    const contentLen = data.length + 2;
    const padLen = (blockSize - (contentLen % blockSize)) % blockSize;
    const out = new Uint8Array(data.length + padLen + 2); // total = exact multiple of blockSize
    out.set(data);
    crypto.getRandomValues(out.subarray(data.length, out.length - 2)); // fill padding with random bytes
    out[out.length - 2] = (data.length >> 8) & 0xff;
    out[out.length - 1] = data.length & 0xff;
    return out;
  }

  function _unpadMessage(buf) {
    if (buf.length < 2) return null;
    const len = (buf[buf.length-2] << 8) | buf[buf.length-1];
    if (len < 0 || len > buf.length-2) return null;
    return new TextDecoder().decode(buf.subarray(0, len));
  }

  const P2PCrypto = {
    // In-memory cache of derived keys per room, with TTL timestamps
    _keyCache:    new Map(), // cacheKey → { key, expires }
    _KEY_TTL_MS:  30 * 60 * 1000, // 30-minute TTL per derived key

    // ── Double Ratchet state per peer ──────────────────────────────────────────
    // Implements a simplified Signal-protocol-style symmetric ratchet:
    //   Root key → chain key → per-message keys (HKDF-SHA-256).
    // Each message advances the chain; old message keys are deleted immediately.
    // Without the current chain state, a captured ciphertext cannot be decrypted
    // even if the long-term PSK is later compromised (forward secrecy within session).
    //
    // We use the symmetric-ratchet half only (not full DH ratchet) because:
    //   - No server needed — PSK authenticated key exchange via existing ntfy channel
    //   - ECDH ephemeral key exchange bootstraps the root key at session start
    //   - After bootstrap, each send/recv advances its own chain independently
    _ratchetState: new Map(), // peerId → { rootKey:CryptoKey, sendChain:CryptoKey, recvChain:CryptoKey, sendN:int, recvN:int }
    _ephemeralKeys: null,     // { publicKey, privateKey } ECDH keypair for this session

    async _ensureEphemeralKeys() {
      if (this._ephemeralKeys) return this._ephemeralKeys;
      this._ephemeralKeys = await crypto.subtle.generateKey(
        { name: 'ECDH', namedCurve: 'P-256' },
        true,
        ['deriveKey', 'deriveBits']
      );
      return this._ephemeralKeys;
    },

    async getEphemeralPublicKeyJwk() {
      const kp = await this._ensureEphemeralKeys();
      return crypto.subtle.exportKey('jwk', kp.publicKey);
    },

    // Derives a shared root key from our private key + their public key (ECDH)
    // then seeds the send/recv chain keys using HKDF with PSK as ikm context.
    async initRatchetWithPeer(peerId, theirPublicKeyJwk, weAreInitiator) {
      if (!gget('jv5_ratchet_global', true)) return; // toggle off → skip
      try {
        const kp = await this._ensureEphemeralKeys();
        const theirPub = await crypto.subtle.importKey(
          'jwk', theirPublicKeyJwk,
          { name: 'ECDH', namedCurve: 'P-256' },
          false, []
        );
        // ECDH shared secret → 32 bytes
        const sharedBits = await crypto.subtle.deriveBits(
          { name: 'ECDH', public: theirPub },
          kp.privateKey, 256
        );
        // HKDF: mix shared secret with PSK bytes as salt for mutual authentication
        const pskBytes = new TextEncoder().encode(P2P_PSK);
        const hkdfMaterial = await crypto.subtle.importKey(
          'raw', sharedBits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']
        );
        // SECURITY FIX #3: Mark all ratchet keys non-extractable (false).
        // Extractable:true allowed crypto.subtle.exportKey() to dump the full ratchet
        // state, destroying the forward secrecy guarantee.
        const rootKey = await crypto.subtle.deriveKey(
          { name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: new TextEncoder().encode('jv5-ratchet-root') },
          hkdfMaterial,
          { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
        );
        // Derive send/recv chains (initiator sends on A, receiver on B)
        const sendInfo = new TextEncoder().encode(weAreInitiator ? 'jv5-chain-A' : 'jv5-chain-B');
        const recvInfo = new TextEncoder().encode(weAreInitiator ? 'jv5-chain-B' : 'jv5-chain-A');
        const sendChain = await crypto.subtle.deriveKey(
          { name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: sendInfo },
          hkdfMaterial,
          { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
        );
        const recvChain = await crypto.subtle.deriveKey(
          { name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: recvInfo },
          hkdfMaterial,
          { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
        );
        this._ratchetState.set(peerId, { rootKey, sendChain, recvChain, sendN: 0, recvN: 0 });
      } catch (e) {
        _devWarn('[Ratchet] Init failed for peer', e.message);
      }
    },

    // Advance a chain key one step via HMAC-SHA-256(chainKey, "advance") → new chain key
    // Returns the message key (used once then discarded — forward secrecy).
    async _advanceChain(chainKey) {
      const enc = new TextEncoder();
      // Message key = HMAC(chainKey, 0x01) — used once then discarded
      const msgKeyBuf  = await crypto.subtle.sign('HMAC', chainKey, enc.encode('\x01'));
      // New chain key = HMAC(chainKey, 0x02) — replaces current chain
      const newChainBuf = await crypto.subtle.sign('HMAC', chainKey, enc.encode('\x02'));

      // Import new chain key for next iteration
      const newChain = await crypto.subtle.importKey(
        'raw', newChainBuf,
        { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']
      );
      // Import message key as AES-GCM-256 (SHA-256 HMAC = 32 bytes = exact key length)
      const msgKey = await crypto.subtle.importKey(
        'raw', new Uint8Array(msgKeyBuf),  // explicit Uint8Array — no slice needed, already 32 bytes
        { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
      );
      return { msgKey, newChain };
    },

    async ratchetEncrypt(plainObj, peerId) {
      const state = this._ratchetState.get(peerId);
      if (!state) return null; // no ratchet session — caller falls back to PSK
      try {
        const { msgKey, newChain } = await this._advanceChain(state.sendChain);
        state.sendChain = newChain;
        const n = state.sendN++;
        const iv = crypto.getRandomValues(new Uint8Array(12));
        const padded = _padMessage(JSON.stringify({ ...plainObj, _rn: n, _ts: Date.now() }));
        const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, msgKey, padded);
        return {
          encrypted: true, ratchet: true, peerId, n,
          iv: Array.from(iv),
          ciphertext: Array.from(new Uint8Array(ct)),
          v: 2
        };
      } catch (e) {
        _devWarn('[Ratchet] Encrypt failed:', e.message);
        return null;
      }
    },

    async ratchetDecrypt(encObj, fromPeerId) {
      const state = this._ratchetState.get(fromPeerId);
      if (!state || !encObj.ratchet) return null;
      try {
        const { msgKey, newChain } = await this._advanceChain(state.recvChain);
        state.recvChain = newChain;
        const iv = new Uint8Array(encObj.iv);
        const ct = new Uint8Array(encObj.ciphertext);
        const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, msgKey, ct);
        const plain = JSON.parse(_unpadMessage(new Uint8Array(dec)));
        const now = Date.now();
        // SECURITY FIX #8: Replay window tightened from 6 hours to 5 minutes.
        // A 6h window allowed captured ratchet messages to be replayed for hours.
        if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 5) return null; // stale
        delete plain._rn; delete plain._ts;
        return plain;
      } catch (e) {
        _devWarn('[Ratchet] Decrypt failed:', e.message);
        return null;
      }
    },

    clearRatchetState(peerId) {
      if (peerId) this._ratchetState.delete(peerId);
      else { this._ratchetState.clear(); this._ephemeralKeys = null; }
    },

    // ── Key derivation — PBKDF2 (default) or Argon2id-emulation ──────────────
    // True Argon2id requires WASM and is not available in crypto.subtle.
    // We emulate its memory-hardness property by:
    //   1. Running PBKDF2 with SHA-512 (more expensive per iteration than SHA-256)
    //   2. Using 350 000 iterations (≈ 2.5× the default 150 000)
    //   3. Stretching with an additional HKDF pass using a domain-separated info tag
    // This raises the cost for offline brute-force attacks on captured ciphertext
    // without adding a WASM dependency. The toggle is honored at key-derivation time.
    async _deriveKey(password, roomId, sessionSalt = null) {
      const useArgon2Emulation = gget('jv5_use_argon2', true);
      // SECURITY FIX #2: Never store the plaintext password as a Map key —
      // an attacker iterating _keyCache.keys() would recover room passphrases directly.
      // Hash it first so the cache key reveals nothing about the password.
      const pwHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
      const pwHashHex = Array.from(new Uint8Array(pwHashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
      const cacheKey = `${roomId}:${pwHashHex}:${sessionSalt || 'default'}:${useArgon2Emulation ? 'a2' : 'p2'}`;
      const cached = this._keyCache.get(cacheKey);
      if (cached && cached.expires > Date.now()) return cached.key;

      const enc = new TextEncoder();
      const keyMaterial = await crypto.subtle.importKey(
        'raw', enc.encode(password),
        { name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey']
      );

      // SECURITY FIX #5: Salt was previously derived from the room ID alone — a known,
      // public string. An attacker who knows the room ID could precompute rainbow tables
      // offline against common passwords. Mixing in the per-install random PSK half
      // (_storedHalfB) means precomputation requires knowing that user's private GM value.
      const salt = enc.encode('jv5-p2p-v2:' + roomId + ':' + _storedHalfB.slice(0, 16) + (sessionSalt ? ':' + sessionSalt : ''));

      let key;
      if (useArgon2Emulation) {
        // Phase 1: PBKDF2-SHA-512 at 350 000 iterations → 64 raw bytes
        const stretchedBits = await crypto.subtle.deriveBits(
          { name: 'PBKDF2', salt, iterations: 350000, hash: 'SHA-512' },
          keyMaterial, 512
        );
        // Phase 2: HKDF domain separation → final AES-GCM-256 key
        const hkdfBase = await crypto.subtle.importKey(
          'raw', stretchedBits, { name: 'HKDF' }, false, ['deriveKey']
        );
        key = await crypto.subtle.deriveKey(
          { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('jv5-argon2-emu'), info: enc.encode(roomId) },
          hkdfBase,
          { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
        );
      } else {
        // Standard PBKDF2-SHA-256 at 150 000 iterations
        key = await crypto.subtle.deriveKey(
          { name: 'PBKDF2', salt, iterations: 150000, hash: 'SHA-256' },
          keyMaterial,
          { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
        );
      }

      this._keyCache.set(cacheKey, { key, expires: Date.now() + this._KEY_TTL_MS });
      return key;
    },

    async encrypt(plainObj, password, roomId) {
      if (!password) throw new Error('Password required for encryption');
      const key = await this._deriveKey(password, roomId);
      const iv = crypto.getRandomValues(new Uint8Array(12));

      const payload = {
        ...plainObj,
        _seq: (this._seqCounter = ((this._seqCounter || 0) + 1)),
        _ts: Date.now()
      };

      const padded = _padMessage(JSON.stringify(payload));

      const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, padded);

      return {
        encrypted: true,
        iv: Array.from(iv),
        ciphertext: Array.from(new Uint8Array(ciphertext)),
        v: 1
      };
    },

    async decrypt(encObj, password, roomId) {
      if (!encObj || !encObj.encrypted) return null;
      if (!password) return null;
      try {
        const key = await this._deriveKey(password, roomId);
        const iv = new Uint8Array(encObj.iv);
        const ciphertext = new Uint8Array(encObj.ciphertext);
        const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
        const unpadded = _unpadMessage(new Uint8Array(decrypted));
        if (!unpadded) {
          console.warn('[P2PCrypto] Unpad failed — likely corrupt or legacy unpadded message');
          return null;
        }
        const plain = JSON.parse(unpadded);
        const now = Date.now();
        // SECURITY FIX #8: Replay window tightened from 6 hours to 5 minutes.
        if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 5) {
          console.warn('[P2PCrypto] Dropped stale/replayed message');
          return null;
        }
        delete plain._seq;
        delete plain._ts;
        return plain;
      } catch (e) {
        console.warn('[P2PCrypto] Decryption failed (wrong password or corrupted data)');
        return null;
      }
    },

    isRoomEncrypted(room) {
      try { return !!gget(`jv5_p2p_enc_${room}`, false); } catch { return false; }
    },
    setRoomEncrypted(room, enabled) {
      try { gset(`jv5_p2p_enc_${room}`, enabled); } catch {}
    },
    clearKeyCache() { this._keyCache.clear(); }
  };

  // P2PCrypto is intentionally NOT exposed to unsafeWindow — no need to
  // give page scripts access to the encrypt/decrypt methods or key cache.


  // ─── PRE-SHARED KEY (PSK) — GM-SPLIT DERIVATION ────────────────────────────
  // SECURITY MODEL: The PSK has two halves:
  //   • Source half (_pskSrc): embedded in the script. Because this script is
  //     published, the source half is NOT SECRET — anyone can read it.
  //     It functions as a "community namespace" separator, not a secret.
  //   • GM half (_storedHalfB): randomly generated per-user on first run, stored
  //     in GM storage. This half IS secret — it never appears in the script.
  //     If an attacker dumps a victim's GM storage they can reconstruct the PSK,
  //     so users are advised to enable a custom passphrase for room-level privacy.
  //   Final PSK = SHA-256(srcHalf : gmHalf : 'psk-derive') — 32 hex chars.
  // This design means passive relay-level attackers (ntfy.sh operators) cannot
  // decrypt messages without knowing the per-user GM half.

  // NOTE on topic seed (#10): _topicSeed is used to derive ntfy topic names via
  // SHA-256. Because the seed is in the script source, a determined observer who
  // knows the seed can subscribe to the same topics and see encrypted envelopes
  // (room names, ciphertext volumes, timestamps) even without breaking AES-GCM.
  // This is acceptable for a community script but users should be aware of it.

  const _pskA   = 'jv5\u2010comm';
  const _pskB   = 'unity\u20102026\u2010K9#mXq';
  const _pskSrc = _pskA + _pskB + 'R2pL8wNzAe';

  // Half B: load from GM storage, or generate and store a random 32-byte hex string
  const _pskGmKey = 'jv5_psk_b';
  let _storedHalfB = (() => { try { return GM_getValue(_pskGmKey, null); } catch { return null; } })();
  if (!_storedHalfB) {
    _storedHalfB = Array.from(crypto.getRandomValues(new Uint8Array(32)))
      .map(b => b.toString(16).padStart(2, '0')).join('');
    try { GM_setValue(_pskGmKey, _storedHalfB); } catch {}
  }

  // Final PSK = SHA-256(sourceHalf + ':' + gmHalf + ':psk-derive')
  // Both halves must be present to arrive at the same key — mutual dependency.
  // Use the full 64 hex chars (256 bits) — do NOT truncate.
  const P2P_PSK = await (async () => {
    const combined = _pskSrc + ':' + _storedHalfB + ':psk-derive';
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(combined));
    return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
  })();
  const P2P_PSK_ROOM = '__psk__'; // synthetic room ID so PSK key != per-room key

  /**
   * Encrypts a message payload using the PSK.
   * Called on EVERY outgoing community message regardless of room.
   * Returns { psk: true, iv, ciphertext, v } wrapper.
   */
  // ── Helper: returns the active PSK for encryption/decryption ──────────────
  // If the user has set a custom passphrase, its SHA-256 hash is mixed with
  // the GM-split PSK via HKDF so the result is domain-separated from both
  // inputs. This means:
  //   • Custom passphrase users form a private sub-group within any room
  //   • Clearing the passphrase reverts to the default PSK seamlessly
  //   • The raw passphrase is never stored — only its SHA-256 hash
  //   • Neither input half alone is sufficient to derive the active key
  // SECURITY FIX #6: Previously the custom PSK was derived then immediately exported
  // to a hex string and stored in a plain JS object. A non-extractable CryptoKey is
  // opaque to devtools memory snapshots; a plain hex string is not.
  // Now we cache the CryptoKey itself (non-extractable) and pass it directly to
  // encrypt/decrypt, skipping the export step entirely.
  let _activePskCache = null; // { hash: string, cryptoKey: CryptoKey } — invalidated when customHash changes

  async function _getActivePskKey() {
    const customHash = (() => { try { return GM_getValue('jv5_custom_psk_hash', ''); } catch { return ''; } })();
    if (customHash && customHash.length === 64) {
      if (_activePskCache && _activePskCache.hash === customHash) return _activePskCache.cryptoKey;
      const enc = new TextEncoder();
      const ikm = await crypto.subtle.importKey('raw', enc.encode(customHash), { name: 'HKDF' }, false, ['deriveKey']);
      const cryptoKey = await crypto.subtle.deriveKey(
        { name: 'HKDF', hash: 'SHA-256', salt: enc.encode(P2P_PSK), info: enc.encode('jv5-custom-psk-v1') },
        ikm,
        { name: 'AES-GCM', length: 256 },
        false,   // ← non-extractable: key cannot be exported from the browser
        ['encrypt', 'decrypt']
      );
      _activePskCache = { hash: customHash, cryptoKey };
      return cryptoKey;
    }
    _activePskCache = null;
    return null; // null → caller uses the default PSK string path
  }

  // Keep the string-based getter for backward compat with any path that still needs it
  async function _getActivePsk() {
    const customHash = (() => { try { return GM_getValue('jv5_custom_psk_hash', ''); } catch { return ''; } })();
    if (customHash && customHash.length === 64) {
      // For the string path, we still need a hex PSK.  Derive it but never cache as string.
      const enc = new TextEncoder();
      const ikm = await crypto.subtle.importKey('raw', enc.encode(customHash), { name: 'HKDF' }, false, ['deriveKey']);
      const key  = await crypto.subtle.deriveKey(
        { name: 'HKDF', hash: 'SHA-256', salt: enc.encode(P2P_PSK), info: enc.encode('jv5-custom-psk-v1') },
        ikm, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
      );
      const raw = await crypto.subtle.exportKey('raw', key);
      return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('');
    }
    return P2P_PSK;
  }

  async function pskEncrypt(plainObj) {
    try {
      return await P2PCrypto.encrypt(plainObj, await _getActivePsk(), P2P_PSK_ROOM);
    } catch (e) {
      console.warn('[JV5-PSK] Encrypt failed:', e.message);
      return null;
    }
  }

  /**
   * Decrypts a PSK-encrypted message.
   * Tries the active PSK first. If that fails and a custom passphrase is set,
   * falls back to the default PSK — handles messages sent before the passphrase
   * was changed, and prevents silent message loss during transition.
   */
  async function pskDecrypt(encObj) {
    if (!encObj || !encObj.encrypted) return null;
    try {
      const result = await P2PCrypto.decrypt(encObj, await _getActivePsk(), P2P_PSK_ROOM);
      if (result) return result;
      // If custom passphrase is set and active-PSK decryption failed,
      // try the default PSK as fallback (graceful degradation for mixed rooms)
      const customSet = (() => { try { return GM_getValue('jv5_custom_psk_set', false); } catch { return false; } })();
      if (customSet) {
        return await P2PCrypto.decrypt(encObj, P2P_PSK, P2P_PSK_ROOM);
      }
      return null;
    } catch {
      return null;
    }
  }

  /** Returns true if a received message envelope looks like a PSK-encrypted packet. */
  function isPskEncrypted(msg) {
    return !!(msg && msg.encrypted === true && msg.psk === true);
  }

  // ─── ADVANCED WEBRTC MANAGER (Phase 2 - Direct P2P, Maximum Privacy) ───────
  // Uses ntfy.sh ONLY for temporary signaling (SDP offers/answers + ICE candidates).
  // Actual chat messages flow over encrypted WebRTC DataChannels (browser-native DTLS).
  // This makes long-term relay exposure minimal.
  // Designed for small groups (mesh). Falls back gracefully to ntfy pubsub.
  // Very advanced: connection management, quality monitoring, E2EE integration, auto-reconnect.

  /**
   * Mesh WebRTC manager for direct peer-to-peer chat.
   *
   * Uses ntfy.sh **only** for the brief signaling phase (SDP offer/answer +
   * ICE candidates). Once a `RTCDataChannel` is open, messages flow over
   * browser-native DTLS-encrypted channels, bypassing the public relay
   * entirely.
   *
   * Design decisions:
   * - `negotiated:true, id:0` — both peers create the channel independently,
   *   eliminating the `ondatachannel` round-trip.
   * - **Glare prevention** — the peer with the lexicographically lower ID
   *   always sends the offer; the other waits. Eliminates simultaneous-offer
   *   failures in a mesh.
   * - Signaling messages are deduplicated via `_seenSignalingIds` so re-polls
   *   on the ntfy topic never re-process old SDP / ICE events.
   * - Auto-reconnect on `failed` / `disconnected` state after 3 s.
   *
   * Exposed on `unsafeWindow.jv5WebRTC` for diagnostics.
   */
  class WebRTCManager {
    constructor() {
      this.peerConnections  = new Map(); // peerId -> RTCPeerConnection
      this.dataChannels     = new Map(); // peerId -> RTCDataChannel
      this.signalingTopic   = P2P_TOPIC_SIGNAL;
      this.isEnabled        = false;
      this.localPeerId      = null;
      this.room             = null;
      this.onMessageCallback  = null;
      this.onStatusCallback   = null;
      // Dedup: track signaling message IDs so re-polls don't re-process them
      this._seenSignalingIds  = new Set();
      this._lastSignalingId   = '10m'; // ntfy since parameter
      // Glare prevention: only peers with the lexicographically LOWER id send the offer.
      // The other side waits for the offer via signaling. This prevents both sides
      // simultaneously creating offers and causing connection failures.
      this._pendingConnect    = new Set(); // peerIds we've already sent an offer to
      // ICE server config — deliberately avoids Google STUN to reduce IP
      // disclosure to Google's infrastructure. Uses Cloudflare + open STUN servers.
      // Note: STUN by nature reveals your public IP to the STUN server; this only
      // moves the disclosure away from Google. True IP privacy requires a TURN relay
      // (which would need a self-hosted server). mDNS candidate filtering below
      // further limits local LAN IP leakage inside the SDP.
      this.iceServers = [
        { urls: 'stun:stun.cloudflare.com:3478' },    // Cloudflare (no Google)
        { urls: 'stun:stun.nextcloud.com:443' },       // Nextcloud open STUN
        { urls: 'stun:stunserver.stunprotocol.org' },  // stunprotocol.org (non-commercial)
      ];
    }

    async enable(room, localPeerId) {
      if (this.isEnabled) return;
      this.room = room;
      this.localPeerId = localPeerId;
      this.isEnabled = true;

      // Listen for signaling messages on a dedicated ntfy topic
      this._startSignalingListener();

      bus.emit('webrtcEnabled', { room });
      if (this.onStatusCallback) this.onStatusCallback('enabled');
      _devLog('[WebRTC] High-security direct P2P mode enabled for room', room);
    }

    disable() {
      this.isEnabled = false;
      this._closeAllConnections();
      bus.emit('webrtcDisabled');
      if (this.onStatusCallback) this.onStatusCallback('disabled');
    }

    async connectToPeer(remotePeerId) {
      if (!this.isEnabled) return;
      if (this.peerConnections.has(remotePeerId)) return;
      if (this._pendingConnect.has(remotePeerId)) return;

      // Glare prevention: only the peer with the lower ID sends the offer.
      // The higher-ID peer waits; it will receive an offer via signaling and answer it.
      if (!this._shouldInitiate(remotePeerId)) {
        _devLog('[WebRTC] Waiting for offer from a peer (they have lower ID)');
        return;
      }

      this._pendingConnect.add(remotePeerId);
      try {
        const pc = new RTCPeerConnection({ iceServers: this.iceServers });
        this.peerConnections.set(remotePeerId, pc);

        // negotiated:true with a fixed id means both sides use the same channel
        // without needing an extra ondatachannel round-trip
        const dc = pc.createDataChannel('jv5-chat', { negotiated: true, id: 0 });
        this._setupDataChannel(dc, remotePeerId);

        pc.onicecandidate = (event) => {
          if (event.candidate) {
            // Filter out host candidates that contain a raw private/LAN IP.
            // mDNS candidates (*.local) are safe — they don't expose your real LAN IP.
            // srflx (server-reflexive) candidates contain the public IP, which is
            // unavoidable for WebRTC — but we still skip plain host candidates.
            const cand = event.candidate.candidate || '';
            const isHostWithRealIP = /typ host/.test(cand) && !/\.local\b/.test(cand);
            if (isHostWithRealIP) return; // drop LAN IP — keep mDNS + srflx only
            this._sendSignalingMessage(remotePeerId, { type: 'ice-candidate', candidate: event.candidate });
          }
        };

        pc.onconnectionstatechange = () => {
          const state = pc.connectionState;
          _devLog('[WebRTC] initiator peer →', state);
          if (this.onStatusCallback) this.onStatusCallback(`peer-${remotePeerId}-${state}`);
          if (state === 'failed' || state === 'disconnected') {
            this._pendingConnect.delete(remotePeerId);
            this._reconnectPeer(remotePeerId);
          }
          if (state === 'closed') {
            this._pendingConnect.delete(remotePeerId);
          }
        };

        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);
        this._sendSignalingMessage(remotePeerId, { type: 'offer', sdp: offer.sdp });
      } catch (e) {
        this._pendingConnect.delete(remotePeerId);
        this.peerConnections.delete(remotePeerId);
        _devWarn('[WebRTC] connectToPeer failed:', e && e.message);
      }
    }

    _setupDataChannel(dc, remotePeerId) {
      this.dataChannels.set(remotePeerId, dc);

      dc.onopen = () => {
        if (this.onStatusCallback) this.onStatusCallback(`peer-${remotePeerId}-connected`);
      };

      dc.onmessage = (event) => {
        try {
          // SECURITY FIX #10: Reject oversized messages to prevent memory DoS.
          // A malicious peer could send a huge JSON blob to hang JSON.parse.
          if (typeof event.data === 'string' && event.data.length > 65536) {
            _devWarn('[WebRTC] Dropped oversized message from peer', remotePeerId, '— size:', event.data.length);
            return;
          }
          const data = JSON.parse(event.data);
          if (this.onMessageCallback) {
            this.onMessageCallback(remotePeerId, data);
          }
        } catch (e) {}
      };

      dc.onclose = () => {
        this.dataChannels.delete(remotePeerId);
      };
    }

    async _handleSignalingMessage(fromPeer, message) {
      if (!this.isEnabled || fromPeer === this.localPeerId) return;

      let pc = this.peerConnections.get(fromPeer);
      if (!pc) {
        pc = new RTCPeerConnection({ iceServers: this.iceServers });
        this.peerConnections.set(fromPeer, pc);

        pc.onicecandidate = (event) => {
          if (event.candidate) {
            const cand = event.candidate.candidate || '';
            const isHostWithRealIP = /typ host/.test(cand) && !/\.local\b/.test(cand);
            if (isHostWithRealIP) return;
            this._sendSignalingMessage(fromPeer, { type: 'ice-candidate', candidate: event.candidate });
          }
        };

        pc.onconnectionstatechange = () => {
          const state = pc.connectionState;
          _devLog('[WebRTC] answerer peer →', state);
          if (this.onStatusCallback) this.onStatusCallback(`peer-${fromPeer}-${state}`);
          if (state === 'failed' || state === 'disconnected') {
            this._pendingConnect.delete(fromPeer);
            this._reconnectPeer(fromPeer);
          }
          if (state === 'closed') this._pendingConnect.delete(fromPeer);
        };

        // Answerer side: use the same negotiated DataChannel config so both sides
        // create the channel independently (no ondatachannel event needed).
        const dc = pc.createDataChannel('jv5-chat', { negotiated: true, id: 0 });
        this._setupDataChannel(dc, fromPeer);
      }

      if (message.type === 'offer') {
        await pc.setRemoteDescription({ type: 'offer', sdp: message.sdp });
        const answer = await pc.createAnswer();
        await pc.setLocalDescription(answer);
        this._sendSignalingMessage(fromPeer, { type: 'answer', sdp: answer.sdp });
      } else if (message.type === 'answer') {
        await pc.setRemoteDescription({ type: 'answer', sdp: message.sdp });
      } else if (message.type === 'ice-candidate' && message.candidate) {
        try {
          await pc.addIceCandidate(message.candidate);
        } catch (e) {}
      }
    }

    async _sendSignalingMessage(toPeer, payload) {
      const signalingMsg = {
        v: 1,
        from: this.localPeerId,
        to: toPeer,
        room: this.room,
        webrtc: true,
        payload,
        ts: Date.now()
      };

      // Encrypt signaling (SDP/ICE) before hitting ntfy — prevents relay from
      // seeing IP addresses embedded in SDP and ICE candidates.
      let body;
      try {
        const enc = await pskEncrypt(signalingMsg);
        // SECURITY FIX #1: Never fall back to plaintext — SDP/ICE contain IP addresses.
        // If encryption fails for any reason, drop the message entirely.
        if (!enc) {
          _devWarn('[WebRTC] Signaling message dropped — PSK encryption failed (refusing plaintext fallback)');
          return;
        }
        enc.psk = true; enc.webrtcSig = true; // flag so receiver knows to decrypt
        body = JSON.stringify(enc);
      } catch (e) {
        _devWarn('[WebRTC] Signaling message dropped — encryption threw:', e && e.message);
        return; // SECURITY FIX #1: drop, never send plaintext
      }

      GM_xmlhttpRequest({
        method: 'POST',
        url: `${_p2pGetRelay()}/${this.signalingTopic}`,
        headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
        data: body
      });
    }

    _startSignalingListener() {
      // Poll the signaling topic (lightweight, separate from main chat poll).
      // Tracks lastSignalingId so re-polls never re-process old SDP/ICE messages.
      const pollSignaling = async () => {
        if (!this.isEnabled) return;
        try {
          const since = this._lastSignalingId || '10m';
          const res = await gmFetch(
            `${_p2pGetRelay()}/${this.signalingTopic}/json?since=${since}&poll=1`,
            { timeout: 10000, headers: _p2pRelayHeaders() }
          );
          if (res.ok) {
            const text = await res.text();
            let maxId = this._lastSignalingId ? Number(this._lastSignalingId) : 0;
            for (const line of text.split('\n')) {
              if (!line.trim()) continue;
              try {
                const evt = JSON.parse(line);
                // Track ntfy event ID to advance the since cursor
                if (evt.id) {
                  const numId = Number(evt.id);
                  if (numId > maxId) maxId = numId;
                  // Dedup: skip events we've already processed
                  if (this._seenSignalingIds.has(evt.id)) continue;
                  this._seenSignalingIds.add(evt.id);
                  // SECURITY FIX #7: Prune the dedup set to prevent unbounded growth.
                  // A hostile relay flooding fake IDs would otherwise exhaust memory.
                  if (this._seenSignalingIds.size > 500) this._seenSignalingIds.clear();
                  // Keep set bounded
                  if (this._seenSignalingIds.size > 500) {
                    const arr = [...this._seenSignalingIds];
                    arr.slice(0, 100).forEach(id => this._seenSignalingIds.delete(id));
                  }
                }
                if (evt.event === 'message' && evt.message) {
                  let msg = JSON.parse(evt.message);
                  // Decrypt if PSK-encrypted signaling packet
                  if (msg.psk && msg.webrtcSig && msg.encrypted) {
                    const dec = await pskDecrypt(msg);
                    if (!dec) continue; // drop undecryptable signaling
                    msg = dec;
                  }
                  if (msg.webrtc && msg.to === this.localPeerId && msg.room === this.room) {
                    // Validate msg.from is a well-formed peer ID before using it as a
                    // RTCPeerConnection key. Crafted signaling with arbitrary 'from'
                    // strings could otherwise inject phantom connections or interfere
                    // with the glare-prevention timestamp-suffix comparison.
                    const _fromOk = typeof msg.from === 'string'
                      && /^[a-z0-9]{4,20}$/i.test(msg.from)
                      && msg.from !== this.localPeerId;
                    if (_fromOk) this._handleSignalingMessage(msg.from, msg.payload);
                  }
                }
              } catch {}
            }
            if (maxId > Number(this._lastSignalingId || 0)) {
              this._lastSignalingId = String(maxId);
            }
          }
        } catch (e) {}

        if (this.isEnabled) {
          setTimeout(pollSignaling, 4000);
        }
      };

      pollSignaling();
    }

    sendToPeer(remotePeerId, data) {
      const dc = this.dataChannels.get(remotePeerId);
      if (dc && dc.readyState === 'open') {
        try { dc.send(JSON.stringify(data)); return true; } catch { return false; }
      }
      return false; // Fallback to ntfy will happen in caller
    }

    // Broadcast to every open DataChannel. Returns count of successful deliveries.
    sendToAll(data) {
      let sent = 0;
      for (const [peerId] of this.dataChannels) {
        if (this.sendToPeer(peerId, data)) sent++;
      }
      return sent;
    }

    // Number of peers with an open DataChannel right now
    connectedCount() {
      let n = 0;
      for (const [, dc] of this.dataChannels) if (dc.readyState === 'open') n++;
      return n;
    }

    // Glare prevention: compare the numeric timestamp suffix (last 4 base-36 chars of
    // the peer ID) so the peer who joined earlier (lower timestamp) sends the offer.
    // This is more reliable than lexicographic comparison because random prefixes can
    // produce false inversions when both peers connect simultaneously.
    // Falls back to full-string comparison if either suffix is not a valid base-36 number.
    _shouldInitiate(remotePeerId) {
      const localTs  = parseInt((this.localPeerId  || '').slice(-4), 36);
      const remoteTs = parseInt(remotePeerId.slice(-4), 36);
      if (!isNaN(localTs) && !isNaN(remoteTs) && localTs !== remoteTs) {
        return localTs < remoteTs;
      }
      // Fallback: full string comparison (handles equal timestamps or malformed IDs)
      return (this.localPeerId || '') < remotePeerId;
    }

    _reconnectPeer(peerId) {
      this.peerConnections.get(peerId)?.close();
      this.peerConnections.delete(peerId);
      this.dataChannels.delete(peerId);
      // Attempt reconnect after delay
      setTimeout(() => {
        if (this.isEnabled) this.connectToPeer(peerId);
      }, 3000);
    }

    _closeAllConnections() {
      this.peerConnections.forEach(pc => pc.close());
      this.peerConnections.clear();
      this.dataChannels.clear();
    }

    setMessageHandler(cb) { this.onMessageCallback = cb; }
    setStatusHandler(cb) { this.onStatusCallback = cb; }
  }  // close WebRTCManager class

  const webrtcManager = new WebRTCManager();
  // Expose a safe read-only diagnostic proxy — NOT the live webrtcManager object.
  // Page scripts can read status/counts but cannot call disable(), connectToPeer(),
  // or access the raw RTCPeerConnection objects.
  try {
    Object.defineProperty(unsafeWindow, 'jv5WebRTC', {
      get: () => ({
        isEnabled:      webrtcManager.isEnabled,
        connectedCount: webrtcManager.connectedCount(),
        room:           webrtcManager.room,
        // No disable(), connectToPeer(), peerConnections, dataChannels, or signalingTopic
      }),
      configurable: false,
      enumerable:   false,
    });
  } catch {}


  const selectorEngine = new SelectorEngine();
  // selectorEngine intentionally NOT exposed to unsafeWindow — page scripts have no business
  // reading the live selector map or triggering self-healing externally.

  // Legacy compat + auto start healing
  const SELECTOR_CONFIG = selectorEngine.primary; // for old code paths

  function _initRemoteConfig() { selectorEngine.loadRemote(); }
  // Start self-healing early
  if (document.readyState !== 'loading') selectorEngine.startSelfHealing();
  else document.addEventListener('DOMContentLoaded', () => selectorEngine.startSelfHealing(), { once: true });


  // ─── IDENTITY HELPERS ─────────────────────────────────────────────────────────

  /**
   * Returns the persistent anonymous peer ID for this browser, generating and
   * storing one on first call.
   * @returns {string} Peer ID prefixed with 'p' (e.g. `"pab3f91c2a4b2"`).
   */
  function _p2pGetPeerId() {
    let id = GM_getValue(P2P_GM_PEERID, null);
    if (!id) {
      // Use CSPRNG — Math.random() is not cryptographically secure
      const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)))
        .map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 12);
      id = 'p' + rand + Date.now().toString(36).slice(-4);
      GM_setValue(P2P_GM_PEERID, id);
    }
    return id;
  }
  function _p2pGetNickname() { return GM_getValue(P2P_GM_NICKNAME, 'Anonymous'); }
  function _p2pGetRoom()      { return GM_getValue(P2P_GM_ROOM, 'global'); }
  function _p2pGetCharId() {
    // 1. Current URL is a character card page — extract, persist, and return the ID.
    const urlMatch = location.pathname.match(/\/characters\/([A-Za-z0-9_-]{4,})/);
    if (urlMatch) {
      const id = urlMatch[1].replace(/-/g, '').slice(0, 20);
      try { GM_setValue(P2P_GM_LAST_CHAR, id); } catch {}
      return id;
    }
    // 2. /chats/ page — fall back to last stored character ID
    if (location.pathname.startsWith('/chats/')) {
      try { const stored = GM_getValue(P2P_GM_LAST_CHAR, null); if (stored) return stored; } catch {}
    }
    return null;
  }

  function _p2pGetCharAvatar() {
    try {
      const selectors = [
        '[class*="characterAvatar"] img',
        '[class*="character-avatar"] img',
        '[class*="CharacterAvatar"] img',
        '[class*="characterImage"] img',
        '[class*="character-image"] img',
        '[class*="chatHeader"] img',
        '[class*="ChatHeader"] img',
        '[class*="chat-header"] img',
        'header img',
      ];
      for (const sel of selectors) {
        const el = document.querySelector(sel);
        if (el?.src && !el.src.startsWith('data:') && el.naturalWidth > 0) return el.src;
        if (el?.src && !el.src.startsWith('data:')) return el.src;
      }
    } catch {}
    return null;
  }

  function _p2pGetCharName() {
    try {
      const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
      if (raw && raw.length > 1 && raw.length < 60) {
        try { GM_setValue(P2P_GM_LAST_CHAR_NAME, raw); } catch {}
        return raw;
      }
      const selectors = [
        '[class*="characterName"]', '[class*="character_name"]',
        '[class*="character-name"]', '[class*="chatHeader"] h1',
        '[class*="ChatHeader"] h1', '[class*="chat-header"] h1', 'header h1',
      ];
      for (const sel of selectors) {
        const el = document.querySelector(sel);
        const t = el?.textContent?.trim();
        if (t && t.length > 1 && t.length < 60) {
          try { GM_setValue(P2P_GM_LAST_CHAR_NAME, t); } catch {}
          return t;
        }
      }
    } catch {}
    // Fallback: /chats/ page — use stored name
    if (location.pathname.startsWith('/chats/')) {
      try { const n = GM_getValue(P2P_GM_LAST_CHAR_NAME, null); if (n) return n; } catch {}
    }
    return null;
  }

  async function _p2pGetTopic(room) {
    if (room === 'char') {
      const cid = _p2pGetCharId();
      // Validate: only allow alphanumeric IDs (1–20 chars) to prevent topic injection
      if (cid && /^[A-Za-z0-9]{1,20}$/.test(cid)) {
        const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-char-' + cid));
        return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
      }
    }
    return P2P_TOPIC_GLOBAL;
  }

  // ─── NICK SANITISATION ────────────────────────────────────────────────────────
  // Applied at parse time (incoming messages) and at storage time (outgoing nick).
  // _esc() handles display-time XSS escaping separately — this strips at the data
  // layer so that malicious bytes never reach GM storage or in-memory message objects.
  function _sanitizeNick(str) {
    if (typeof str !== 'string') return 'Anonymous';
    return str
      .replace(/[\x00-\x1f\x7f]/g, '')   // strip control chars and null bytes
      .replace(/[<>"'`]/g, '')             // strip HTML/attr-injection chars
      .trim()
      .slice(0, 24)
      || 'Anonymous';
  }

  // Sanitises the replyTo sub-object embedded inside incoming messages.
  // replyTo passes through _safeMergeMsg as a raw nested object, so its own
  // fields are not individually validated. Without this, a crafted sender
  // could embed oversized strings, proto-pollution keys, or unexpected types
  // inside replyTo that reach GM history storage and rendered quote blocks.
  function _sanitizeReplyTo(rt) {
    if (!rt || typeof rt !== 'object' || Array.isArray(rt)) return null;
    const safe = Object.create(null);
    if (typeof rt.id   === 'string') safe.id   = rt.id.slice(0, 10);
    if (typeof rt.peer === 'string') safe.peer  = rt.peer.slice(0, 64);
    safe.nick = _sanitizeNick(rt.nick || 'Someone');
    safe.text = typeof rt.text === 'string' ? rt.text.slice(0, 800) : '';
    return safe;
  }

  // ─── HISTORY STORAGE ──────────────────────────────────────────────────────────

  function _p2pGetHistory() {
    try {
      const raw = JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]'));
      if (!Array.isArray(raw)) return [];
      // Sanitise each stored entry through the same field allowlist used for
      // incoming messages. Prevents a tampered GM store from injecting unexpected
      // fields into rendered bubbles (e.g. __proto__, constructor, oversized blobs).
      return raw.map(entry => {
        if (!entry || typeof entry !== 'object') return null;
        const safe = Object.create(null);
        for (const k of _MSG_ALLOWED_FIELDS) {
          if (k in entry) {
            const v = entry[k];
            // Enforce string fields are strings, numeric fields are numbers
            if (k === 'ts' && typeof v === 'number') safe[k] = v;
            else if (k === 'v'  && typeof v === 'number') safe[k] = v;
            else if (k === 'nick') safe[k] = _sanitizeNick(v);
            else if (typeof v === 'string' || typeof v === 'boolean' ||
                     Array.isArray(v)      || typeof v === 'number') safe[k] = v;
            else if (v !== null && typeof v === 'object') safe[k] = v; // replyTo etc.
          }
        }
        return Object.keys(safe).length ? safe : null;
      }).filter(Boolean);
    } catch { return []; }
  }
  function _p2pAddHistory(msg) {
    try {
      const hist = _p2pGetHistory();
      hist.push(msg);
      if (hist.length > P2P_MAX_HISTORY) hist.splice(0, hist.length - P2P_MAX_HISTORY);
      GM_setValue(P2P_GM_HISTORY, JSON.stringify(hist));
    } catch (e) { _devWarn('[JV5] Failed to save P2P history:', e); }
  }

  // ─── BLOCKED LIST STORAGE ─────────────────────────────────────────────────────

  function _p2pGetBlocked()    { try { return new Set(JSON.parse(GM_getValue(P2P_GM_BLOCKED, '[]'))); } catch { return new Set(); } }
  function _p2pBlockPeer(id)   { try { const b = _p2pGetBlocked(); b.add(id);    GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }
  function _p2pUnblockPeer(id) { try { const b = _p2pGetBlocked(); b.delete(id); GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }

  let _chatVerifiedMap      = new Map(); 
  let _chatVerifiedLastFetch = 0;

  function _p2pFetchVerified() {
    if (!P2P_VERIFIED_URL) return;
    // SECURITY: HTTPS-only + SSRF block. The verified-URL is user-configurable
    // and fetched via GM_xmlhttpRequest (no CORS). Without this guard, an
    // attacker who can write GM storage could point it at an internal endpoint.
    if (!/^https:\/\/.+/.test(P2P_VERIFIED_URL) || _SSRF_BLOCK_RE.test(P2P_VERIFIED_URL)) return;
    if (Date.now() - _chatVerifiedLastFetch < 24 * 60 * 60 * 1000) return;
    _chatVerifiedLastFetch = Date.now();
    try {
      GM_xmlhttpRequest({
        redirect: 'manual',
        method: 'GET', url: P2P_VERIFIED_URL, timeout: 8000,
        onload(r) {
          if (r.status < 200 || r.status >= 300) return;
          try {
            const data = JSON.parse(r.responseText);
            if (!Array.isArray(data.verified)) return;
            _chatVerifiedMap = new Map();
            for (const e of data.verified) {
              // Validate peer ID format — prevents false badges from crafted JSON.
              if (typeof e.peer !== 'string' || !/^[a-z0-9]{4,20}$/i.test(e.peer)) continue;
              // Sanitize display name — remote JSON is untrusted content.
              _chatVerifiedMap.set(e.peer, _sanitizeNick(e.name || e.peer));
            }
          } catch {}
        },
      });
    } catch {}
  }

  // ─── PERSISTENT MSGID DEDUP ───────────────────────────────────────────────
  // Pre-load recent seen msgIds from GM storage so that messages which arrived
  // before a page refresh are not re-displayed as duplicates.
  // Only keeps entries from the past 6 hours (matching the freshness window).
  const _GM_SEEN_MSGS   = 'jv5_seen_msgids';
  const _SEEN_MSG_TTL   = 1000 * 60 * 60 * 6; // 6 hours
  function _loadPersistedSeenIds() {
    try {
      const raw = GM_getValue(_GM_SEEN_MSGS, '[]');
      const arr = JSON.parse(raw);
      const now = Date.now();
      // Stored as [{id, ts}] — prune stale entries on load
      const fresh = arr.filter(e => e && typeof e.ts === 'number' && (now - e.ts) < _SEEN_MSG_TTL);
      return new Set(fresh.map(e => e.id));
    } catch { return new Set(); }
  }
  function _persistSeenIds(set) {
    try {
      const now = Date.now();
      const arr = [...set].slice(-500).map(id => ({ id, ts: now }));
      GM_setValue(_GM_SEEN_MSGS, JSON.stringify(arr));
    } catch {}
  }

  // ─── ONE-TIME HISTORY MIGRATION (v5.7.6) ─────────────────────────────────
  // Pre-5.7.6, outgoing messages were saved to GM history as PSK ciphertext
  // blobs (no peer/ts/text/nick fields). Those entries rendered as
  // "Anonymous #???? Invalid Date" with blank text on every modal reopen.
  // This migration runs once on load and purges encrypted blobs from history
  // so users get a clean slate without needing to manually clear storage.
  ;(() => {
    try {
      const migKey = 'jv5_hist_migrated_576';
      if (GM_getValue(migKey, false)) return; // already done
      const raw = JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]'));
      if (!Array.isArray(raw)) return;
      // Keep only entries that have both peer and text (valid plaintext messages)
      const clean = raw.filter(e => e && typeof e === 'object' && e.peer && e.text);
      GM_setValue(P2P_GM_HISTORY, JSON.stringify(clean));
      GM_setValue(migKey, true);
    } catch {}
  })();

  const chatStore = {
    
    open:       false,
    listEl:     null,    

    messages:   [],      
    seenMsgIds: _loadPersistedSeenIds(), // pre-seeded from GM storage
    seenNtfyIds: new Set(), 

    blocked:    new Set(),

    pinnedText: '',

    replyingTo: null,   

    isAdmin:    false,

    onlineMap:  new Map(), 

    lastSend:   0,

    atBottom:   true,

    reset() {
      this.open            = false;
      this.listEl          = null;
      this.messages        = [];
      this.seenMsgIds      = _loadPersistedSeenIds(); // reload from GM on reset
      this.seenNtfyIds     = new Set();
      this.replyingTo      = null;
      this.atBottom        = true;
      this._roomPasswords  = {}; // SECURITY: clear cached encryption passwords on reset
    },
  };


  // ─── CRYPTO UTILITIES ─────────────────────────────────────────────────────────

  /**
   * Returns the hex-encoded SHA-256 digest of `str` using the Web Crypto API.
   * Used to hash admin passwords client-side before storage and comparison.
   * @param {string} str
   * @returns {Promise<string>} 64-character lowercase hex string.
   */
  async function _sha256(str) {
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
    return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
  }

  // SECURITY FIX #4: Admin password hashing upgraded from raw SHA-256 to PBKDF2-SHA-256.
  // SHA-256 is fast — an attacker who dumps GM storage can brute-force common passwords
  // in seconds. PBKDF2 with 150k iterations raises the cost by ~150,000×.
  // Storage key: 'jv4_p2p_admin_hash_v2' → JSON { salt: base64, hash: hex }
  // Backward compat: if only the legacy SHA-256 hash exists, it still works and
  // auto-upgrades to PBKDF2 on first successful login.
  const _GM_ADMIN_HASH_V2 = 'jv4_p2p_admin_hash_v2';

  async function _adminPbkdf2Hash(password) {
    const salt = crypto.getRandomValues(new Uint8Array(16));
    const enc  = new TextEncoder();
    const km   = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
    const key  = await crypto.subtle.deriveKey(
      { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 150000 },
      km, { name: 'AES-GCM', length: 256 }, true, ['encrypt']
    );
    const raw  = await crypto.subtle.exportKey('raw', key);
    return {
      salt: _arrToB64(salt),
      hash: Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('')
    };
  }

  async function _adminVerifyPbkdf2(password, stored) {
    try {
      const enc  = new TextEncoder();
      const salt = _b64ToArr(stored.salt);
      const km   = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
      const key  = await crypto.subtle.deriveKey(
        { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 150000 },
        km, { name: 'AES-GCM', length: 256 }, true, ['encrypt']
      );
      const raw  = await crypto.subtle.exportKey('raw', key);
      const hash = Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('');
      // Constant-time hex comparison via HMAC to avoid timing oracle
      const hk   = await crypto.subtle.importKey('raw', new Uint8Array(32), { name:'HMAC', hash:'SHA-256' }, false, ['sign','verify']);
      const a    = await crypto.subtle.sign('HMAC', hk, enc.encode(hash));
      const b2   = await crypto.subtle.sign('HMAC', hk, enc.encode(stored.hash));
      const av   = new Uint8Array(a), bv = new Uint8Array(b2);
      if (av.length !== bv.length) return false;
      let diff = 0; for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i];
      return diff === 0;
    } catch { return false; }
  }
  // Returns first 12 hex chars of SHA-256 — used as a privacy-preserving reporter fingerprint
  function _sha256Short(str) {
    // Synchronous best-effort: use cached peer ID hash or fall back to sliced ID
    // (Full async SHA-256 not needed for a short display fingerprint)
    return typeof str === 'string' ? str.slice(0, 8) + '…' : 'anon';
  }

  // ─── ADMIN HMAC SIGNING (replaces plaintext hash transmission) ─────────────
  // Instead of sending P2P_ADMIN_HASH in every message (allowing anyone monitoring
  // ntfy.sh to capture it and spoof admin styling), we sign each message with an
  // HMAC derived from the hash. The verifier re-derives the same HMAC locally.
  // The secret never leaves GM storage.
  //
  // Ed25519 toggle (jv5_use_ed25519_admin) switches the HMAC hash from SHA-256
  // to SHA-512 AND pre-stretches the key through HKDF with a domain-separation tag.
  // This:
  //   • Doubles the MAC output length (512 vs 256 bits)
  //   • Adds domain separation so a captured admin signature cannot be replayed
  //     as a non-admin HMAC in a different context
  //   • Makes offline brute-force more expensive (SHA-512 is ~2× slower on 32-bit CPUs)
  // Note: true Ed25519 (asymmetric) requires the signatory to hold a private key
  // and verifiers to use a public key — that would require a key-registration ceremony
  // out of scope for a serverless userscript. This is the best practical equivalent
  // using symmetric primitives (HMAC) available in crypto.subtle.

  async function _adminDeriveKey(hashAlgo) {
    const enc = new TextEncoder();
    const raw = await crypto.subtle.importKey(
      'raw', enc.encode(P2P_ADMIN_HASH),
      { name: 'HMAC', hash: hashAlgo }, false, ['sign', 'verify']
    );
    if (!gget('jv5_use_ed25519_admin', true)) return raw;
    // Strong mode: stretch through HKDF for domain separation
    const bits = await crypto.subtle.sign('HMAC', raw, enc.encode('jv5-admin-kdf-v1'));
    return crypto.subtle.importKey(
      'raw', bits,
      { name: 'HMAC', hash: 'SHA-512' }, false, ['sign', 'verify']
    );
  }

  /**
   * Signs a message ID + timestamp.
   * Strong mode ON  → HMAC-SHA-512 with HKDF-stretched key + domain tag.
   * Strong mode OFF → HMAC-SHA-256 (legacy compatible).
   */
  async function _signAdminMsg(msgId, ts) {
    if (!P2P_ADMIN_HASH) return '';
    try {
      const useStrong = gget('jv5_use_ed25519_admin', true);
      const hashAlgo  = useStrong ? 'SHA-512' : 'SHA-256';
      const key = await _adminDeriveKey(hashAlgo);
      const enc = new TextEncoder();
      // Domain-separation tag prevents cross-context reuse of admin signatures
      const msg = useStrong
        ? `jv5-admin-v2:${msgId}:${ts}`
        : `${msgId}:${ts}`;
      const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg));
      return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
    } catch { return ''; }
  }

  /**
   * Verifies an admin HMAC signature using constant-time comparison.
   * Automatically detects legacy (SHA-256) vs strong (SHA-512) signatures
   * by length: SHA-256 → 64 hex chars, SHA-512 → 128 hex chars.
   */
  async function _verifyAdminSig(msgId, ts, sig) {
    if (!P2P_ADMIN_HASH || !sig) return false;
    try {
      // Convert the received hex signature to bytes for crypto.subtle.verify
      const _hexToBytes = hex => new Uint8Array(hex.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) || []);

      const useStrong = gget('jv5_use_ed25519_admin', true);
      const hashAlgo  = useStrong ? 'SHA-512' : 'SHA-256';
      const key       = await _adminDeriveKey(hashAlgo);
      const enc       = new TextEncoder();
      const msgStr    = useStrong ? `jv5-admin-v2:${msgId}:${ts}` : `${msgId}:${ts}`;

      // Use crypto.subtle.verify — performs a constant-time MAC comparison
      // internally, so there's no timing oracle even if the JS engine optimises XOR loops.
      const sigBytes = _hexToBytes(sig);
      const ok = sigBytes.length > 0 && await crypto.subtle.verify(
        'HMAC', key, sigBytes, enc.encode(msgStr)
      );
      if (ok) return true;

      // Fallback: try the opposite toggle mode for backward compat with older senders
      const altHash  = useStrong ? 'SHA-256' : 'SHA-512';
      const altKey   = await _adminDeriveKey(altHash);
      const altMsg   = useStrong ? `${msgId}:${ts}` : `jv5-admin-v2:${msgId}:${ts}`;
      return sigBytes.length > 0 && await crypto.subtle.verify(
        'HMAC', altKey, sigBytes, enc.encode(altMsg)
      );
    } catch { return false; }
  }

  // ─── PERSISTENT ADMIN REPLAY PREVENTION ───────────────────────────────────
  // seenMsgIds is in-memory only and gets cleared on modal reset, so a captured
  // admin-signed message could be replayed after a page refresh within the 6h window.
  // We persist seen admin msgIds to GM storage (capped to prevent unbounded growth).
  const _GM_ADMIN_SEEN = 'jv5_admin_seen_ids';
  const _adminSeenIds = (() => {
    try {
      const stored = GM_getValue(_GM_ADMIN_SEEN, '[]');
      const parsed = JSON.parse(stored);
      return Array.isArray(parsed) ? new Set(parsed) : new Set();
    } catch { return new Set(); }
  })();
  function _isAdminMsgSeen(msgId) {
    return _adminSeenIds.has(msgId);
  }
  function _markAdminMsgSeen(msgId) {
    _adminSeenIds.add(msgId);
    // Cap to 500 entries; remove oldest 200 when over limit
    if (_adminSeenIds.size > 500) {
      const arr = [..._adminSeenIds];
      arr.slice(0, 200).forEach(id => _adminSeenIds.delete(id));
    }
    try { GM_setValue(_GM_ADMIN_SEEN, JSON.stringify([..._adminSeenIds])); } catch {}
  }

  // Security & leak prevention: track document-level click dismiss listeners
  // so they can be forcibly removed when the P2P modal closes (prevents memory leaks
  // from closures capturing picker elements when closed via backdrop/Escape instead of outside-click)
  let _p2pActiveDismiss = new Set();

  function _p2pCleanupDismissListeners() {
    _p2pActiveDismiss.forEach(fn => {
      try { document.removeEventListener('click', fn, true); } catch {}
    });
    _p2pActiveDismiss.clear();
  }


  const chatNet = {

    abortFlag:    false,
    xhr:          null,
    reconnTimer:  null,
    backoffMs:    P2P_BACKOFF_MIN,

    lastEventId:  null,
    _onlineHandler: null,   // window 'online' → immediate repoll
    _currentTopic:  null,   // track active topic for online-handler closure

    typingXhr:          null,
    typingProcessed:    0,
    typingStreamTimer:  null,
    typingLastId:       '1m',
    typingTimers:       new Map(),
    typingSendTimer:    null,

    hbXhr:         null,
    hbProcessed:   0,
    hbStreamTimer: null,
    hbSendTimer:   null,
    hbLastId:      '2m',

    async connect() {
      this.disconnect();
      this.abortFlag    = false;
      this.backoffMs    = P2P_BACKOFF_MIN;
      // Sync blocked list from persistent storage so mutes applied while
      // modal was closed are honoured immediately on re-open.
      chatStore.blocked = _p2pGetBlocked();
      // Guard: if room is 'char' but we're not on a character page, fall back to global
      // so the topic doesn't silently collapse to the global topic while the UI
      // thinks it's in char-room (causing char messages to appear in global).
      const room = _p2pGetRoom();
      if (room === 'char' && !_p2pGetCharId()) {
        try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
      }
      const topic = await _p2pGetTopic(_p2pGetRoom());
      this._currentTopic = topic;
      this._startPoll(topic);
      this._startTypingStream();
      this._startHb();

      // ── WebRTC: enable direct P2P for this room ──────────────────────────────
      // Wire the DataChannel message handler → _handleEvent so incoming WebRTC
      // messages are processed the same way as ntfy messages.
      // ntfy remains active as a fallback for peers not yet connected via WebRTC.
      const _webrtcRoom = _p2pGetRoom();
      const _webrtcPeer = _p2pGetPeerId();
      webrtcManager.setMessageHandler((fromPeer, data) => {
        // IMPORTANT: do NOT check data.peer here — PSK-encrypted outer envelopes
        // intentionally omit peer. Only check data.v === 1 (same as _handleEvent).
        // data.peer would be undefined for encrypted payloads → all WebRTC messages
        // would be silently dropped. peer is restored post-decrypt by _safeMergeMsg.
        if (data && data.v === 1) {
          _handleEvent({ message: JSON.stringify(data) }).catch(() => {});
        }
      });
      webrtcManager.setStatusHandler((status) => {
        if (status.startsWith('peer-') && status.endsWith('-connected')) {
          const connCount = webrtcManager.connectedCount();
          _devLog('[WebRTC] Direct channel open — ' + connCount + ' peer(s) connected');
        }
      });
      webrtcManager.enable(_webrtcRoom, _webrtcPeer).catch(() => {});

      // When the device comes back online, kick off a poll immediately
      this._onlineHandler = () => {
        if (!this.abortFlag) {
          this.backoffMs = P2P_BACKOFF_MIN;
          clearTimeout(this.reconnTimer);
          this._startPoll(this._currentTopic);
        }
      };
      window.addEventListener('online', this._onlineHandler);
    },

    disconnect() {
      this.abortFlag = true;
      this._abort('xhr');
      this._abort('typingXhr');
      this._abort('hbXhr');
      clearTimeout(this.reconnTimer);
      clearTimeout(this.typingStreamTimer);
      clearTimeout(this.hbStreamTimer);
      this._currentTopic = null;
      if (this._onlineHandler) {
        window.removeEventListener('online', this._onlineHandler);
        this._onlineHandler = null;
      }
      if (this.typingSendTimer) { clearTimeout(this.typingSendTimer); this.typingSendTimer = null; }
      if (this.hbSendTimer)     { clearTimeout(this.hbSendTimer);     this.hbSendTimer = null; }
      this.typingTimers.forEach(t => clearTimeout(t));
      this.typingTimers.clear();
      const el = document.getElementById('jv4-p2p-typing');
      if (el) el.innerHTML = '';
      // Disable WebRTC — closes all DataChannels and peer connections cleanly
      try { webrtcManager.disable(); } catch {}
      // Evict all cached derived keys and ratchet state — nothing lingers in memory
      try { P2PCrypto.clearKeyCache(); } catch {}
      try { P2PCrypto.clearRatchetState(); } catch {}
    },

    _abort(key) {
      if (this[key]) { try { this[key].abort(); } catch {} this[key] = null; }
    },

    // ── MAIN MESSAGE POLL ──────────────────────────────────────────────────────
    // Why poll=1 instead of streaming?
    // GM_xmlhttpRequest does NOT reliably fire onprogress for long-lived streaming
    // connections — it buffers the response until the server closes the socket
    // (ntfy.sh does this every ~30-60s), making messages appear with massive delay.
    // poll=1 tells ntfy.sh to return immediately with whatever is queued, so each
    // round-trip takes only a few hundred milliseconds over WiFi or mobile data.
    _startPoll(topic) {
      if (this.abortFlag) return;
      this._abort('xhr');
      clearTimeout(this.reconnTimer);

      const since = this.lastEventId || '30m';

      this.xhr = GM_xmlhttpRequest({
        redirect: 'manual',
        method:  'GET',
        url:     `${_p2pGetRelay()}/${topic}/json?since=${since}&poll=1`,
        headers: _p2pRelayHeaders({ 'Accept': 'application/x-ndjson' }),
        timeout: 15000,

        onload: (r) => {
          if (this.abortFlag) return;
          if (r.responseText?.trim()) {
            this._processChunk(r.responseText);
          }
          // Still alive — show green dot and schedule next poll
          this.backoffMs = P2P_BACKOFF_MIN;
          chatRender.updateStatus('connected');
          this.reconnTimer = setTimeout(() => this._startPoll(topic), _jitteredPollMs(P2P_POLL_MS));
        },

        // Network error — use exponential backoff
        onerror: () => {
          if (this.abortFlag) return;
          chatRender.updateStatus('reconnecting');
          const delay = this.backoffMs;
          this.backoffMs = Math.min(
            Math.round(this.backoffMs * P2P_BACKOFF_MULT),
            P2P_BACKOFF_MAX
          );
          this.reconnTimer = setTimeout(() => this._startPoll(topic), delay);
        },

        // poll=1 almost never times out, but treat it the same as no-error
        ontimeout: () => {
          if (this.abortFlag) return;
          this.backoffMs = P2P_BACKOFF_MIN;
          chatRender.updateStatus('connected');
          this.reconnTimer = setTimeout(() => this._startPoll(topic), _jitteredPollMs(P2P_POLL_MS));
        },
      });
    },

    _processChunk(text) {
      let maxId = this.lastEventId ? Number(this.lastEventId) : 0;
      const msgs = [];
      for (const line of text.split('\n')) {
        if (!line.trim()) continue;
        try {
          const evt = JSON.parse(line);
          if (evt.id) {
            const numId = Number(evt.id);
            // Always advance maxId regardless of dedup state — prevents lastEventId
            // from stalling when all events in a chunk are already seen (Fix #processChunk)
            if (numId > maxId) maxId = numId;
            if (chatStore.seenNtfyIds.has(evt.id)) continue;
            chatStore.seenNtfyIds.add(evt.id);
          }
          if (evt.event === 'message') msgs.push(evt);
        } catch {}
      }
      if (chatStore.seenNtfyIds.size > 2000) {
        // Keep the most recent 1500 IDs; removing too many at once risks seeing
        // an already-shown message again after a reconnect
        const arr = [...chatStore.seenNtfyIds];
        arr.slice(0, 500).forEach(id => chatStore.seenNtfyIds.delete(id));
      }
      if (maxId > Number(this.lastEventId || 0)) {
        this.lastEventId = String(maxId);
      }
      if (msgs.length) {
        (async () => {
          if (this.abortFlag) return;
          for (const evt of msgs) {
            await _handleEvent(evt).catch(e => _devWarn('[JanitorV5] handleEvent:', e && e.message));
          }
        })();
      }
    },

    async send(payload) {
      const topic = await _p2pGetTopic(_p2pGetRoom());
      let responded = false;

      // Watchdog: if no HTTP response within 10 s, treat as a timeout failure
      const watchdog = setTimeout(() => {
        if (responded) return;
        responded = true;
        _showSendFailBanner('timeout', payload);
      }, 10000);

      GM_xmlhttpRequest({
        redirect: 'manual',
        method:   'POST',
        url:      `${_p2pGetRelay()}/${topic}`,
        headers:  _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
        data:     JSON.stringify(payload),
        timeout:  10000,
        onload(r) {
          if (responded) return;
          responded = true;
          clearTimeout(watchdog);
          if (r.status === 429) {
            // Server-side rate limit: show airplane-mode tip + retry option
            _showSendFailBanner('ratelimit', payload);
          } else if (r.status >= 400) {
            // Other server errors (5xx, auth, etc.)
            _showSendFailBanner('failed', payload);
          }
          // 2xx / 3xx → message delivered (redirect:'manual' means redirects stay as 3xx)
          if (r.status >= 200 && r.status < 400) {
            _markMessageDelivered(payload.msgId);
          }
        },
        onerror() {
          if (responded) return;
          responded = true;
          clearTimeout(watchdog);
          _showSendFailBanner('failed', payload);
        },
        ontimeout() {
          if (responded) return;
          responded = true;
          _showSendFailBanner('timeout', payload);
        },
      });
    },

    sendTyping() {
      // SECURITY: nick intentionally omitted — typing indicator goes to a public
      // ntfy topic in plaintext. Including nick would let any observer build a
      // real-time map of who is online and their chosen display names.
      // Recipients display the short peer-ID prefix instead (non-sensitive: already visible in chat).
      GM_xmlhttpRequest({
        redirect: 'manual',
        method:  'POST',
        url:     `${_p2pGetRelay()}/${P2P_TOPIC_TYPING}`,
        headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
        data:    JSON.stringify({ v:1, peer: _p2pGetPeerId(), type: 'typing', ts: Date.now() }),
      });
    },

    _handleTypingEvent(evt) {
      try {
        const msg = JSON.parse(evt.message);
        if (!msg || msg.type !== 'typing' || !msg.peer || msg.peer === _p2pGetPeerId()) return;
        if (chatStore.blocked.has(msg.peer)) return;
        const el = document.getElementById('jv4-p2p-typing');
        if (!el) return;
        // nick is no longer in typing payloads (privacy fix). Fall back to the
        // peer's known nick from an existing chat bubble, or their short ID.
        const knownNick = (() => {
          const bubble = document.querySelector(`[data-peer="${CSS.escape(msg.peer)}"]`);
          return bubble?.closest('.jv4-p2p-bubble')?.querySelector('.jv4-p2p-nick')?.textContent || null;
        })();
        const nick = knownNick ? knownNick.slice(0, 20) : `${msg.peer.slice(0, 6)}…`;
        if (this.typingTimers.has(msg.peer)) clearTimeout(this.typingTimers.get(msg.peer));
        let span = el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`);
        if (!span) {
          span = document.createElement('span');
          span.dataset.typingPeer = msg.peer;
          el.appendChild(span);
        }
        span.textContent = nick;
        this._renderTypingText(el);
        const t = setTimeout(() => {
          el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`)?.remove();
          this._renderTypingText(el);
          this.typingTimers.delete(msg.peer);
        }, P2P_TYPING_TTL);
        this.typingTimers.set(msg.peer, t);
      } catch {}
    },

    _renderTypingText(el) {
      [...el.childNodes].filter(n => n.nodeType === 3).forEach(n => n.remove());
      const peers = el.querySelectorAll('[data-typing-peer]');
      if (!peers.length) return;
      const names = [...peers].map(s => s.textContent);
      const label = names.length === 1
        ? `${names[0]} is typing…`
        : names.length === 2
          ? `${names[0]} and ${names[1]} are typing…`
          : 'Several people are typing…';
      el.appendChild(document.createTextNode(' ' + label));
    },

    _startTypingStream() {
      this._abort('typingXhr');
      clearTimeout(this.typingStreamTimer);
      if (this.abortFlag) return;
      this.typingProcessed = 0;
      this.typingStreamTimer = setTimeout(() => {
        if (!this.abortFlag) this._startTypingStream();
      }, 120000);
      this.typingXhr = GM_xmlhttpRequest({
        redirect: 'manual',
        method:  'GET',
        url:     `${_p2pGetRelay()}/${P2P_TOPIC_TYPING}/json?since=${this.typingLastId}`,
        headers: _p2pRelayHeaders({ 'Accept': 'application/x-ndjson' }),
        onprogress: (r) => {
          if (this.abortFlag) return;
          if (!r.responseText) return;
          const chunk = r.responseText.slice(this.typingProcessed);
          this.typingProcessed = r.responseText.length;
          for (const line of chunk.split('\n')) {
            if (!line.trim()) continue;
            try {
              const e = JSON.parse(line);
              if (e.id) this.typingLastId = e.id;
              if (e.event === 'message') this._handleTypingEvent(e);
            } catch {}
          }
        },
        onload:  () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 100); },
        onerror: () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 2000); },
      });
    },

    _sendHb() {
      const myId = _p2pGetPeerId();
      chatStore.onlineMap.set(myId, Date.now());
      chatRender.updateOnlineCount();
      GM_xmlhttpRequest({
        redirect: 'manual',
        method:  'POST',
        url:     `${_p2pGetRelay()}/${P2P_TOPIC_HB}`,
        headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
        data:    JSON.stringify({ v:1, peer: myId, type: 'hb', ts: Date.now() }),
      });
    },

    _handleHbEvent(evt) {
      try {
        const msg = JSON.parse(evt.message);
        if (!msg || msg.type !== 'hb' || !msg.peer) return;
        const isNewPeer = !chatStore.onlineMap.has(msg.peer);
        chatStore.onlineMap.set(msg.peer, Date.now());
        const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
        for (const [peer, ts] of chatStore.onlineMap) if (ts < cutoff) chatStore.onlineMap.delete(peer);
        chatRender.updateOnlineCount();

        // Attempt WebRTC connection to newly-seen peers.
        // connectToPeer() is a no-op if:
        //   • WebRTC is not enabled  • Already connected  • Already pending
        //   • Glare prevention says this side should wait for the offer
        if (isNewPeer && msg.peer !== _p2pGetPeerId()) {
          if (webrtcManager.isEnabled) webrtcManager.connectToPeer(msg.peer).catch(() => {});
          // Initiate Double Ratchet ECDH handshake for this new peer (if toggle on).
          // We send our ephemeral public key encrypted via PSK so the peer can
          // complete the ECDH and seed their ratchet state. The peer replies
          // with their own public key so we can seed ours.
          if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
            P2PCrypto.getEphemeralPublicKeyJwk().then(async pubJwk => {
              const handshake = {
                v: 1, type: 'ratchet-hello', peer: _p2pGetPeerId(),
                nick: _p2pGetNickname(), pubKey: pubJwk, ts: Date.now()
              };
              const enc = await pskEncrypt(handshake);
              if (enc) { enc.psk = true; enc.room = _p2pGetRoom(); chatNet.send(enc); }
            }).catch(() => {});
          }
        }
      } catch {}
    },

    _startHb() {
      this._abort('hbXhr');
      clearTimeout(this.hbStreamTimer);
      if (this.hbSendTimer) { clearTimeout(this.hbSendTimer); this.hbSendTimer = null; }
      if (this.abortFlag) return;
      this._sendHb();
      // Self-rescheduling jittered timeout instead of a fixed setInterval —
      // a heartbeat landing on the relay every exactly 30.000s is a clean
      // fingerprint; ±20% jitter blurs it without affecting liveness checks.
      const scheduleHb = () => {
        if (this.abortFlag) return;
        this.hbSendTimer = setTimeout(() => { this._sendHb(); scheduleHb(); }, _jitteredPollMs(P2P_HB_SEND_MS));
      };
      scheduleHb();
      const startHbStream = () => {
        this._abort('hbXhr');
        clearTimeout(this.hbStreamTimer);
        if (this.abortFlag) return;
        this.hbProcessed = 0;
        this.hbStreamTimer = setTimeout(() => startHbStream(), 120000);
        this.hbXhr = GM_xmlhttpRequest({
        redirect: 'manual',
          method:  'GET',
          url:     `${_p2pGetRelay()}/${P2P_TOPIC_HB}/json?since=${this.hbLastId}`,
          headers: _p2pRelayHeaders({ 'Accept': 'application/x-ndjson' }),
          onprogress: (r) => {
            if (this.abortFlag) return;
            if (!r.responseText) return;
            const chunk = r.responseText.slice(this.hbProcessed);
            this.hbProcessed = r.responseText.length;
            for (const line of chunk.split('\n')) {
              if (!line.trim()) continue;
              try {
                const e = JSON.parse(line);
                if (e.id) this.hbLastId = e.id;
                if (e.event === 'message') this._handleHbEvent(e);
              } catch {}
            }
          },
          onload:  () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 100); },
          onerror: () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 3000); },
        });
      };
      startHbStream();
    },
  };

  /**
   * Processes a raw ntfy event (or a synthetic one from a WebRTC DataChannel).
   *
   * Responsibilities in order:
   *  1. Parse JSON payload and validate `v === 1`.
   *  2. Room-guard — drop messages that belong to a different room.
   *  3. AES-GCM decryption via `P2PCrypto` when the room is encrypted.
   *  4. Handle reaction sub-type early (no text required).
   *  5. Freshness check — drop messages older than 6 h (replay protection).
   *  6. Per-message dedup via `chatStore.seenMsgIds`.
   *  7. HMAC admin-signature verification for `/command` messages.
   *  8. Blocked-peer filter.
   *  9. Reply notification, history persistence, and bubble render.
   *
   * @param {{ message: string }} ntfyEvt - ntfy.sh event object with a
   *   JSON-encoded `message` field.
   * @returns {Promise<void>}
   */
  // Per-peer incoming rate limiter — prevents a single peer from flooding the
  // local render loop. Tracks the last N timestamps per peer; drops messages
  // that arrive faster than _PEER_RATE_LIMIT allows.
  const _peerRxTimes = new Map(); // peerId → [timestamps]
  const _PEER_RX_WINDOW_MS  = 10000; // rolling window
  const _PEER_RX_MAX_MSGS   = 15;    // max messages per peer per window
  const _MAX_MSG_BYTES       = 65536; // 64 KB hard cap on raw ntfy message string

  function _peerRxAllowed(peerId) {
    if (!peerId) return true; // can't rate-limit before peer is known; filter later
    const now = Date.now();
    const times = (_peerRxTimes.get(peerId) || []).filter(t => now - t < _PEER_RX_WINDOW_MS);
    if (times.length >= _PEER_RX_MAX_MSGS) {
      _devWarn(`[JV5-RateLimit] Peer ${peerId.slice(0,8)} exceeded ${_PEER_RX_MAX_MSGS} msgs/${_PEER_RX_WINDOW_MS}ms — dropping`);
      _peerRxTimes.set(peerId, times);
      return false;
    }
    times.push(now);
    _peerRxTimes.set(peerId, times);
    // Evict stale entries periodically to avoid unbounded map growth
    if (_peerRxTimes.size > 200) {
      for (const [pid, ts] of _peerRxTimes) {
        if (!ts.some(t => now - t < _PEER_RX_WINDOW_MS)) _peerRxTimes.delete(pid);
      }
    }
    return true;
  }

  async function _handleEvent(ntfyEvt) {
    try {
      // Hard cap on raw message size — reject before JSON.parse to avoid
      // memory pressure from oversized payloads crafted by bad actors.
      if (!ntfyEvt.message || ntfyEvt.message.length > _MAX_MSG_BYTES) return;

      const msg = JSON.parse(ntfyEvt.message);
      // NOTE: do NOT check !msg.peer here — PSK-encrypted outer envelopes intentionally
      // omit peer for privacy. peer is restored after pskDecrypt via _safeMergeMsg below.
      if (!msg || msg.v !== 1) return;

      // ── Room guard ─────────────────────────────────────────────────────────────
      // Since we subscribe to ONE topic per session, a room mismatch shouldn't
      // normally happen, but it can when:
      //   • room stored as 'char' but charId is null → topic collapsed to global,
      //     so the UI thinks it's in char-room while reading global messages.
      //   • History replay across rooms on modal reopen.
      // Drop any message whose room tag doesn't match what we're currently viewing.
      const curRoom = _p2pGetRoom();
      if (msg.room && msg.room !== curRoom) return;
      // Extra check: if we're in char-room, only accept messages that were explicitly
      // tagged 'char' (old messages with no room tag are shown for backwards compat
      // in global only, not char rooms).
      if (curRoom === 'char' && msg.room !== 'char') return;
      // ──────────────────────────────────────────────────────────────────────────

      // === PSK Decryption (always-on, transparent to user) ===
      if (isPskEncrypted(msg)) {
        const decrypted = await pskDecrypt(msg);
        if (!decrypted) {
          // Wrong PSK version or corrupted — silently drop
          // This happens when the sender is on a different PSK version (key rotation)
          _devWarn('[JV5-PSK] Dropped message: wrong PSK version or corrupted');
          return;
        }
        // Restore routing fields kept plaintext on the outer envelope
        const roomTag = msg.room;
        // SECURITY: safe field-allowlist merge — prevents prototype pollution from
        // crafted decrypted payloads containing __proto__, constructor, toString, etc.
        _safeMergeMsg(msg, decrypted);
        if (roomTag) msg.room = roomTag;
        // nick is no longer on the outer envelope — it comes from inside ciphertext only
        msg._wasEncrypted = true;
        msg._pskDecrypted = true;

        // If the decrypted body itself is a ratchet-encrypted inner layer (v:2),
        // decrypt that too using the per-peer ratchet chain.
        if (msg.ratchet && msg.v === 2 && msg.peerId && gget('jv5_ratchet_global', true)) {
          const ratchetPlain = await P2PCrypto.ratchetDecrypt(msg, msg.peerId);
          if (ratchetPlain) _safeMergeMsg(msg, ratchetPlain);
          // If ratchetDecrypt fails, msg still has PSK-decrypted data — graceful fallback
        }
      }

      // === Phase 1 Per-Room Decryption (for double-encrypted char rooms) ===
      if (msg.encrypted && !msg._pskDecrypted && P2PCrypto.isRoomEncrypted(curRoom)) {
        if (!chatStore._roomPasswords) chatStore._roomPasswords = {};
        let pw = chatStore._roomPasswords[curRoom];
        if (!pw) {
          pw = await _promptModal({ title: '🔒 Room Password', placeholder: 'Enter shared room password', type: 'password', confirm: 'Decrypt' });
          if (pw) chatStore._roomPasswords[curRoom] = pw;
          else return;
        }
        const decrypted = await P2PCrypto.decrypt(msg, pw, curRoom);
        if (!decrypted) return;
        _safeMergeMsg(msg, decrypted);
        msg._wasEncrypted = true;
      }

      // ── Post-decryption validation ─────────────────────────────────────────
      // Now that all decryption layers are complete, enforce that msg.peer is a
      // well-formed string. A crafted ciphertext that decrypts without a peer
      // field should not reach the message store or the UI.
      if (!msg.peer || typeof msg.peer !== 'string' || msg.peer.length > 64) return;
      // Per-peer incoming rate limit — drop floods before any further processing
      if (!_peerRxAllowed(msg.peer)) return;
      // Sanitise nick at parse time — strips control chars and HTML injection chars
      // before the value reaches GM history storage or the in-memory message list.
      if (msg.nick) msg.nick = _sanitizeNick(msg.nick);
      // Sanitise the replyTo sub-object — its nested fields are NOT covered by
      // _safeMergeMsg's top-level allowlist and arrive as raw nested objects.
      if (msg.replyTo) msg.replyTo = _sanitizeReplyTo(msg.replyTo);
      // ──────────────────────────────────────────────────────────────────────

      if (msg.type === 'reaction' && msg.reactionTo && msg.text) {
        if (msg.peer !== _p2pGetPeerId() && !chatStore.blocked.has(msg.peer)) {
          chatRender.applyReaction(msg.reactionTo, msg.text, msg.peer, false);
        }
        return;
      }

      // ── Double Ratchet ECDH handshake handling ─────────────────────────────
      // ratchet-hello: a peer just saw us and is sending their ephemeral public key.
      //   We store it, init our ratchet state (we are the responder), then reply.
      // ratchet-reply: the peer confirmed receipt and sent their public key back.
      //   We init our ratchet state (we are the initiator).
      if (msg.type === 'ratchet-hello' && msg.pubKey && msg.peer && msg.peer !== _p2pGetPeerId()) {
        if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
          P2PCrypto.initRatchetWithPeer(msg.peer, msg.pubKey, false /* responder */).then(async () => {
            // Reply with our ephemeral public key so the initiator can complete their side
            const pubJwk = await P2PCrypto.getEphemeralPublicKeyJwk();
            const reply = {
              v: 1, type: 'ratchet-reply', peer: _p2pGetPeerId(),
              nick: _p2pGetNickname(), pubKey: pubJwk, ts: Date.now()
            };
            const enc = await pskEncrypt(reply);
            if (enc) { enc.psk = true; enc.room = _p2pGetRoom(); chatNet.send(enc); }
          }).catch(() => {});
        }
        return;
      }
      if (msg.type === 'ratchet-reply' && msg.pubKey && msg.peer && msg.peer !== _p2pGetPeerId()) {
        if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
          P2PCrypto.initRatchetWithPeer(msg.peer, msg.pubKey, true /* initiator */).catch(() => {});
        }
        return;
      }
      // ───────────────────────────────────────────────────────────────────────

      if (!msg.text) return;

      // Basic replay / freshness protection (addresses public relay replay concern)
      const now = Date.now();
      if (msg.ts && Math.abs(now - msg.ts) > 1000 * 60 * 60 * 6) { // 6 hour window
        return; // too old, likely replay or stale
      }

      if (msg.peer === _p2pGetPeerId()) return;

      if (msg.msgId) {
        if (chatStore.seenMsgIds.has(msg.msgId)) return;
        chatStore.seenMsgIds.add(msg.msgId);
        // Prevent unbounded memory growth for very long-lived modal sessions
        if (chatStore.seenMsgIds.size > 2000) {
          const arr = [...chatStore.seenMsgIds];
          arr.slice(0, 500).forEach(id => chatStore.seenMsgIds.delete(id));
        }
        // Persist so dedup survives page refresh (Fix #11)
        _persistSeenIds(chatStore.seenMsgIds);
      }

      // SECURITY: Verify HMAC signature — never trust adminToken in plaintext.
      // _verifyAdminSig re-derives the HMAC locally; the secret never leaves GM storage.
      // Also check persistent GM-backed seen-ids to prevent replay across modal resets.
      if (msg.adminSig && msg.text && msg.text.startsWith('/') && msg.msgId) {
        // Persistent replay guard — survives modal open/close and page refresh
        if (_isAdminMsgSeen(msg.msgId)) return;
        const _isVerifiedAdmin = await _verifyAdminSig(msg.msgId, msg.ts, msg.adminSig);
        if (_isVerifiedAdmin) {
          _markAdminMsgSeen(msg.msgId); // record before executing
          chatCmd.execute(msg.text, msg.peer);
        }
        // Fall through so admin-signed messages still render with gold border
      }

      if (chatStore.blocked.has(msg.peer)) return;

      _maybeNotifyReply(msg);

      _p2pAddHistory(msg);
      chatStore.messages.push(msg);
      chatRender.appendBubble(msg, false);

    } catch (err) {
      _devWarn('[JanitorV5] _handleEvent error:', err && err.message);
    }
  }

  const chatRender = {

    appendBubble(msg, isMine, isNewlySent = false) {
      const list = chatStore.listEl;
      if (!list) return; 

      const myPeer = _p2pGetPeerId();
      const isMe   = isMine || msg.peer === myPeer;

      if (msg.msgId && list.querySelector(`[data-msgid="${CSS.escape(msg.msgId)}"]`)) return;

      const bubble = document.createElement('div');
      bubble.className = `jv4-p2p-bubble ${isMe ? 'jv4-p2p-bubble-me' : 'jv4-p2p-bubble-other'}`;
      bubble.dataset.peer = msg.peer;
      if (msg.msgId) bubble.dataset.msgid = msg.msgId;

      // Async HMAC verify for admin gold border — never trust plain adminToken
      if (!isMe && msg.adminSig && msg.msgId) {
        _verifyAdminSig(msg.msgId, msg.ts, msg.adminSig).then(isAdmin => {
          if (isAdmin) bubble.classList.add('jv4-p2p-bubble-admin');
        });
      }

      if (!isMe && chatStore.blocked.has(msg.peer)) {
        bubble.style.display = 'none';
        bubble.dataset.muted = '1';
      }

      const time     = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
      const nick     = isMe ? 'You' : (msg.nick || 'Anonymous').slice(0, 24);
      const shortId  = (msg.peer || '????').slice(0, 4);
      const verified = !isMe && _chatVerifiedMap.has(msg.peer);
      const verBadge = verified
        ? `<span title="Verified: ${_esc(_chatVerifiedMap.get(msg.peer) || '')}" style="color:#10b981;font-size:11px;margin-left:3px;cursor:default;">✓</span>`
        : '';

      let quoteHTML = '';
      if (msg.replyTo) {
        const rNick = _esc((msg.replyTo.nick || 'Someone').slice(0, 20));
        const rText = _esc((msg.replyTo.text || '').slice(0, 80));
        quoteHTML = `<div class="jv4-p2p-quote">↩ ${rNick}: ${rText}</div>`;
      }

      const muteLabel = chatStore.blocked.has(msg.peer) ? '🔊' : 'mute';
      bubble.innerHTML = `
        <div class="jv4-p2p-meta">
          <span class="jv4-p2p-nick">${_esc(nick)}</span>
          <span class="jv4-p2p-peerid" data-peer="${_esc(msg.peer)}" title="Tap to copy peer ID"
                style="font-size:10px;color:#6b7280;margin-left:2px;cursor:pointer;">#${_esc(shortId)}</span>
          ${verBadge}
          <span class="jv4-p2p-time">${time}</span>
          ${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-reply" title="Reply">↩</button>` : ''}
          ${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-react" title="React">+</button>` : ''}
          ${!isMe ? `<button class="jv4-p2p-mute jv4-act-mute" data-peer="${_esc(msg.peer)}">${_esc(muteLabel)}</button>` : ''}
          ${!isMe ? `<button class="jv4-p2p-mute jv4-act-report" style="color:#ef4444;" title="Report">⚑</button>` : ''}
        </div>
        ${quoteHTML}
        <p class="jv4-p2p-text">${_esc(msg.text).replace(/\n/g, '<br>')}</p>
        ${isMe && isNewlySent ? '<span class="jv4-delivery-status jv4-delivery-pending">Sending…</span>' : ''}
      `;

      bubble.querySelector('.jv4-p2p-peerid')?.addEventListener('click', function () {
        {
          navigator.clipboard.writeText(this.dataset.peer)
            .then(()  => topToast('Peer ID copied!'))
            .catch(() => topToast('Tap and hold to copy manually'));
        }
      });

      bubble.querySelector('.jv4-act-reply')?.addEventListener('click', e => {
        e.stopPropagation();
        chatStore.replyingTo = { id: msg.msgId, nick: msg.nick || 'Anonymous', text: msg.text, peer: msg.peer };
        const strip = document.getElementById('jv4-p2p-reply-strip');
        const txt   = document.getElementById('jv4-p2p-reply-text');
        if (strip && txt) {
          txt.textContent = `↩ ${(msg.nick || 'Anonymous').slice(0, 20)}: ${msg.text.slice(0, 60)}`;
          strip.classList.add('visible');
        }
        document.getElementById('jv4-p2p-input')?.focus();
      });

      bubble.querySelector('.jv4-act-react')?.addEventListener('click', e => {
        e.stopPropagation();
        this.showReactionPicker(bubble, msg.msgId);
      });

      bubble.querySelector('.jv4-act-mute')?.addEventListener('click', e => {
        e.stopPropagation();
        const peerId = msg.peer;
        const isMuted = chatStore.blocked.has(peerId);
        if (isMuted) {
          _p2pUnblockPeer(peerId);
          chatStore.blocked.delete(peerId);
          
          list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"][data-muted="1"]`).forEach(b => {
            b.style.display = '';
            delete b.dataset.muted;
          });
          this._syncMuteButtons(list);
          this.systemMsg('🔊 Unmuted — you can now see messages from this user');
        } else {
          _p2pBlockPeer(peerId);
          chatStore.blocked.add(peerId);
          
          list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"]`).forEach(b => {
            b.style.display = 'none';
            b.dataset.muted = '1';
          });
          this._syncMuteButtons(list);
          this.systemMsg('🔇 Muted — you won\'t see messages from this user');
        }
      });

      bubble.querySelector('.jv4-act-report')?.addEventListener('click', e => {
        e.stopPropagation();
        if (window.confirm(`Report this message from ${(msg.nick || '?').slice(0, 20)}?`)) {
          // SECURITY: Encrypt report payload with PSK before sending to relay.
          // The message text is truncated to 200 chars; reporter peer ID is hashed
          // to a short fingerprint so the relay operator cannot easily identify users.
          const reportPayload = {
            v: 1,
            reporter: msg.peer ? _sha256Short(_p2pGetPeerId()) : 'anon', // hashed fingerprint only
            reported: msg.peer || '',
            nick: (msg.nick || '?').slice(0, 24),
            textSnippet: (msg.text || '').slice(0, 200), // truncated — not full message
            ts: Date.now(),
          };
          pskEncrypt(reportPayload).then(enc => {
            const body = enc ? JSON.stringify(enc) : JSON.stringify(reportPayload);
            GM_xmlhttpRequest({
              redirect: 'manual',
              method:   'POST',
              url:      `${_p2pGetRelay()}/${P2P_TOPIC_REPORTS}`,
              headers:  _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
              data:     body,
              onload()  { topToast('Report submitted'); },
            });
          });
        }
      });

      list.appendChild(bubble);
      
      this._maybeScroll(list);
    },

    systemMsg(text) {
      const list = chatStore.listEl;
      if (!list) return;
      const el = document.createElement('div');
      el.className = 'jv4-p2p-bubble jv4-p2p-bubble-system';
      el.textContent = text;
      list.appendChild(el);
      this._maybeScroll(list);
    },

    _maybeScroll(list) {
      const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
      if (atBottom) {
        list.scrollTop = list.scrollHeight;
        this._hideNewMsgBadge();
      } else {
        this._showNewMsgBadge();
      }
    },

    _showNewMsgBadge() {
      if (document.getElementById('jv4-new-msg-badge')) return;
      const badge = document.createElement('button');
      badge.id = 'jv4-new-msg-badge';
      badge.textContent = '↓ New messages';
      badge.style.cssText = `
        position:absolute; bottom:52px; left:50%; transform:translateX(-50%);
        background:rgba(139,92,246,0.9); color:#fff; border:none; border-radius:20px;
        padding:4px 14px; font-size:11px; cursor:pointer; z-index:10000090;
        box-shadow:0 2px 8px rgba(0,0,0,0.5); animation:ms2-up 0.15s ease;
        white-space:nowrap;
      `;
      badge.addEventListener('click', () => {
        const list = chatStore.listEl;
        if (list) list.scrollTop = list.scrollHeight;
        badge.remove();
      });
      const modal = document.getElementById('jv4-p2p-modal');
      if (modal) { modal.style.position = 'relative'; modal.appendChild(badge); }
    },

    _hideNewMsgBadge() {
      document.getElementById('jv4-new-msg-badge')?.remove();
    },

    _syncMuteButtons(list) {
      list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
        btn.textContent = chatStore.blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
      });
    },

    applyReaction(msgId, emoji, senderPeer, isMine) {
      const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
      if (!bubble) return;
      let bar = bubble.querySelector('.jv4-p2p-reactions');
      if (!bar) { bar = document.createElement('div'); bar.className = 'jv4-p2p-reactions'; bubble.appendChild(bar); }
      let rxn = bar.querySelector(`[data-emoji="${CSS.escape(emoji)}"]`);
      if (!rxn) {
        rxn = document.createElement('button');
        rxn.className = 'jv4-p2p-rxn';
        rxn.dataset.emoji = emoji;
        rxn.dataset.count = '0';
        rxn.innerHTML = `${emoji} <span class="jv4-p2p-rxn-count">1</span>`;
        rxn.addEventListener('click', () => _sendReaction(msgId, emoji));
        bar.appendChild(rxn);
      } else {
        const c = parseInt(rxn.dataset.count || '0') + 1;
        rxn.dataset.count = String(c);
        rxn.querySelector('.jv4-p2p-rxn-count').textContent = String(c);
      }
      if (isMine) rxn.classList.add('mine');
    },

    showReactionPicker(anchorBubble, msgId) {
      document.querySelectorAll('.jv4-rxn-picker').forEach(e => e.remove());
      const picker = document.createElement('div');
      picker.className = 'jv4-rxn-picker';
      for (const emoji of P2P_REACTION_EMOJIS) {
        const btn = document.createElement('button');
        btn.className = 'jv4-rxn-pick-btn'; btn.textContent = emoji;
        btn.addEventListener('click', e => { e.stopPropagation(); _sendReaction(msgId, emoji); picker.remove(); });
        picker.appendChild(btn);
      }
      anchorBubble.style.position = 'relative';
      anchorBubble.appendChild(picker);
      const dismiss = e => {
        if (!picker.contains(e.target)) {
          picker.remove();
          document.removeEventListener('click', dismiss, true);
          _p2pActiveDismiss.delete(dismiss);
        }
      };
      setTimeout(() => {
        document.addEventListener('click', dismiss, true);
        _p2pActiveDismiss.add(dismiss);
      }, 10);
    },

    updatePinnedBar() {
      const bar = document.getElementById('jv4-p2p-pinned-bar');
      if (!bar) return;

      let txt = chatStore.pinnedText;
      try { txt = GM_getValue(P2P_GM_PINNED, '') || ''; } catch { txt = chatStore.pinnedText; }
      chatStore.pinnedText = txt;
      if (txt) {
        bar.innerHTML = `📌 <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(txt)}</span>`;
        bar.classList.add('visible');
      } else {
        bar.innerHTML = '';
        bar.classList.remove('visible');
      }
    },

    updateOnlineCount() {
      const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
      let n = 0; for (const ts of chatStore.onlineMap.values()) if (ts >= cutoff) n++;
      const el = document.getElementById('jv4-p2p-online');
      if (el) el.textContent = n > 0 ? `· ● ${n} online` : '';
    },

    updateStatus(state) {
      const dot = document.getElementById('jv4-p2p-dot');
      const lbl = document.getElementById('jv4-p2p-status-label');
      const room = _p2pGetRoom();
      const charId = _p2pGetCharId();
      const charName = room === 'char' && charId ? (_p2pGetCharName() || 'Character Room') : null;
      const roomLabel = charName ? charName : 'Global Room';
      if (state === 'connected') {
        if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-on';
        if (lbl) lbl.textContent = `Connected · ${roomLabel}`;
      } else if (state === 'reconnecting') {
        if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-warn';
        if (lbl) lbl.textContent = 'Reconnecting…';
      } else {
        if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-off';
        if (lbl) lbl.textContent = 'Connecting…';
      }
    },
  };

  const chatCmd = {
    execute(text, _senderPeer) {
      const parts  = text.slice(1).trim().split(/\s+/);
      const cmd    = parts[0].toLowerCase();
      const target = parts[1] || '';
      const rest   = parts.slice(1).join(' ');
      const list   = chatStore.listEl;

      switch (cmd) {
        case 'pin': {
          if (!rest) break;
          
          chatStore.pinnedText = rest;
          try { GM_setValue(P2P_GM_PINNED, rest); } catch {  }
          chatRender.updatePinnedBar();
          chatRender.systemMsg('📌 Pinned: ' + rest);
          break;
        }
        case 'unpin': {
          chatStore.pinnedText = '';
          try { GM_setValue(P2P_GM_PINNED, ''); } catch {  }
          chatRender.updatePinnedBar();
          chatRender.systemMsg('📌 Pin cleared');
          break;
        }
        default: {  break; }
      }
    },
  };

  /**
   * Validates, optionally encrypts, and dual-path-delivers a chat message.
   *
   * Delivery order:
   *  1. Append an optimistic bubble (marked "Sending…").
   *  2. Broadcast to any open WebRTC DataChannels (sub-50 ms for connected peers).
   *  3. POST to ntfy relay for reliability and offline peers.
   *  4. Kick an accelerated re-poll (250 ms) so concurrent inbound messages
   *     are picked up without waiting the full polling interval.
   *
   * Admin slash-commands bypass the rate limiter and are HMAC-signed before
   * being sent so recipients can verify admin authority without transmitting
   * the secret hash.
   *
   * @param {string}          text      - Raw input text (trimmed internally).
   * @param {HTMLInputElement} inputEl   - The chat input element (for focus restore).
   * @param {HTMLButtonElement} sendBtnEl - Send button (for cooldown animation).
   * @returns {Promise<void>}
   */
  async function _p2pSendMessage(text, inputEl, sendBtnEl) {
    text = text.trim();
    if (!text || text.length > 800) return;

    if (chatStore.isAdmin && text.startsWith('/')) {
      chatCmd.execute(text, _p2pGetPeerId());
      const _adminTs  = Date.now();
      const _adminMid = [...crypto.getRandomValues(new Uint8Array(5))].map(b => b.toString(16).padStart(2,'0')).join('');
      _signAdminMsg(_adminMid, _adminTs).then(async adminSig => {
        const adminPayload = { v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), text, adminSig, ts: _adminTs, msgId: _adminMid, room: _p2pGetRoom() };
        const enc = await pskEncrypt(adminPayload);
        if (enc) { enc.psk = true; enc.room = adminPayload.room; chatNet.send(enc); }
        else chatNet.send(adminPayload); // fallback
      });
      return;
    }

    const now = Date.now();
    const elapsed = now - chatStore.lastSend;
    if (elapsed < P2P_RATE_LIMIT_MS) {
      const remaining = Math.ceil((P2P_RATE_LIMIT_MS - elapsed) / 1000);
      topToast(`Slow down — wait ${remaining}s`);
      _applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS - elapsed);
      return;
    }
    chatStore.lastSend = now;
    _applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS);

    const msgId = [...crypto.getRandomValues(new Uint8Array(5))].map(b => b.toString(16).padStart(2,'0')).join('');
    const room = _p2pGetRoom();
    let finalPayload = {
      v:     1,
      peer:  _p2pGetPeerId(),
      nick:  _p2pGetNickname(),
      text,
      ts:    now,
      room,
      msgId,
    };

    if (chatStore.replyingTo) {
      finalPayload.replyTo = chatStore.replyingTo;
      chatStore.replyingTo = null;
      document.getElementById('jv4-p2p-reply-strip')?.classList.remove('visible');
    }

    // === Double Ratchet inner encryption (forward secrecy, per-peer) ===
    // If a ratchet session exists with ANY connected peer, we encrypt the payload
    // with the ratchet chain first (v:2), then wrap the result in PSK outer layer.
    // Peers without a ratchet session (e.g. late joiners) fall back to PSK-only.
    // The PSK layer is always applied on top regardless — ratchet is additive.
    if (gget('jv5_ratchet_global', true) && P2PCrypto._ratchetState.size > 0) {
      // Broadcast: we ratchet-encrypt once per connected peer since each has its own chain.
      // For simplicity (no server), we send one ratchet-encrypted copy per peer via WebRTC
      // DataChannel, and one PSK-only copy via ntfy for peers without ratchet state.
      // This preserves interoperability with peers that haven't completed handshake yet.
      for (const [peerId, _state] of P2PCrypto._ratchetState) {
        const ratchetEnc = await P2PCrypto.ratchetEncrypt(finalPayload, peerId);
        if (ratchetEnc) {
          // Wrap ratchet ciphertext in PSK for relay confidentiality
          const pskWrapped = await pskEncrypt(ratchetEnc);
          if (pskWrapped) {
            pskWrapped.psk = true;
            pskWrapped.room = room;
            pskWrapped.ratchetFor = peerId; // routing hint (non-sensitive: just a peer ID)
            webrtcManager.sendToPeer?.(peerId, pskWrapped);
          }
        }
      }
    }

    // === PSK Encryption (always-on) ===
    // Every outgoing message is PSK-encrypted before hitting ntfy.sh.
    // ntfy.sh sees only opaque ciphertext. No password prompt needed.
    // NOTE: nick is NOT copied to the outer envelope — it stays inside ciphertext only.
    // room is kept plaintext for routing (non-sensitive: just 'global' or a hashed ID).
    // IMPORTANT: save display fields BEFORE overwriting finalPayload with ciphertext,
    // because appendBubble needs peer/ts/msgId/nick/text to render the local outgoing bubble.
    const _displayPayload = {
      peer: finalPayload.peer, ts: finalPayload.ts, msgId: finalPayload.msgId,
      nick: finalPayload.nick, text: finalPayload.text, replyTo: finalPayload.replyTo,
      room: finalPayload.room,
    };
    const isRoomPwEncrypted = P2PCrypto.isRoomEncrypted(room);
    {
      const encResult = await pskEncrypt(finalPayload);
      if (!encResult) {
        topToast('Encryption failed — message not sent');
        return;
      }
      encResult.psk   = true;  // flag: PSK-encrypted
      encResult.room  = room;  // kept plaintext for topic routing only
      encResult.msgId = msgId; // kept on outer envelope for delivery ACK — not sensitive (just a random ID)
      // encResult.nick intentionally omitted — nickname stays inside ciphertext
      finalPayload = encResult;
    }

    // === Phase 1 Per-Room Encryption (additional layer for char rooms with passwords) ===
    // This is now a second encryption layer on top of PSK for rooms where users
    // explicitly set a password (double-encrypted). Kept for backward compat.
    if (isRoomPwEncrypted) {
      if (!chatStore._roomPasswords) chatStore._roomPasswords = {};
      let pw = chatStore._roomPasswords[room];
      if (!pw) {
        pw = await _promptModal({ title: '🔒 Room Password', placeholder: 'Enter shared room password', type: 'password', confirm: 'Send' });
        if (!pw) {
          topToast('Encryption requires password — message not sent');
          return;
        }
        chatStore._roomPasswords[room] = pw;
      }
      try {
        const encResult = await P2PCrypto.encrypt(finalPayload, pw, room);
        finalPayload = encResult;
        finalPayload.room = room;
      } catch (e) {
        topToast('Encryption failed: ' + e.message);
        return;
      }
    }

    chatStore.seenMsgIds.add(msgId);
    _persistSeenIds(chatStore.seenMsgIds);

    if (chatNet.typingSendTimer) { clearTimeout(chatNet.typingSendTimer); chatNet.typingSendTimer = null; }

    // CRITICAL FIX: store plaintext _displayPayload in history, NOT ciphertext finalPayload.
    // Storing finalPayload (the PSK-encrypted blob) caused every outgoing message entry in GM
    // history to have no peer/ts/text/nick fields. On modal reopen those blobs rendered as
    // "Anonymous #???? Invalid Date" with blank text. The relay still only sees ciphertext
    // (via chatNet.send below). Tampermonkey's sandboxed GM storage is safe for plaintext.
    _p2pAddHistory(_displayPayload);
    chatStore.messages.push(_displayPayload);
    // Use _displayPayload (plaintext fields) for the local bubble — finalPayload is ciphertext
    chatRender.appendBubble(_displayPayload, true, true);

    // ── Dual-path delivery ──────────────────────────────────────────────────────
    // 1. WebRTC DataChannels: sub-50 ms for already-connected peers (best effort).
    // 2. ntfy relay: reliable broadcast for peers not yet on WebRTC + as fallback.
    // seenMsgIds dedup in _handleEvent prevents double-display if a peer receives
    // the message on both paths.
    const _rtcSent = webrtcManager.sendToAll(finalPayload);
    if (_rtcSent > 0) {
      _devLog('[WebRTC] Message sent directly to', _rtcSent, 'peer(s) via DataChannel');
    }
    chatNet.send(finalPayload); // always send via ntfy for reliability

    // Kick an accelerated re-poll shortly after sending so any concurrent
    // incoming messages are picked up without waiting the full poll interval.
    if (chatNet._currentTopic && !chatNet.abortFlag) {
      clearTimeout(chatNet.reconnTimer);
      chatNet.reconnTimer = setTimeout(() => chatNet._startPoll(chatNet._currentTopic), 250);
    }
  }

  function _applySendCooldown(btn, ms) {
    if (!btn) return;
    btn.disabled = true;
    btn.style.position = 'relative';
    btn.style.overflow = 'hidden';
    
    const bar = document.createElement('span');
    bar.style.cssText = `
      position:absolute; left:0; top:0; height:100%;
      background:rgba(255,255,255,0.25); width:100%;
      transition:width ${ms}ms linear;
      pointer-events:none;
    `;
    btn.appendChild(bar);
    requestAnimationFrame(() => { bar.style.width = '0%'; });
    setTimeout(() => {
      btn.disabled = false;
      bar.remove();
    }, ms);
  }

  // ─── SEND-FAIL / RATE-LIMIT BANNER ────────────────────────────────────────────
  // Shows an in-chat alert when a message fails to reach the relay server.
  // type: 'ratelimit' | 'failed' | 'timeout'
  // retryPayload: the original message object to re-send on "Retry" (or null)
  function _showSendFailBanner(type, retryPayload) {
    // Only show if the chat modal is open — avoid ghost banners
    const barEl = document.getElementById('jv4-p2p-bar');
    if (!barEl) return;

    // Deduplicate: remove any existing banner before showing a new one
    document.getElementById('jv4-send-fail-banner')?.remove();

    const SVG_WARN = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
    const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;

    const cfg = {
      ratelimit: {
        title:  'Rate limited — your message may not have sent',
        col:    '#fbbf24',
        bg:     'rgba(251,191,36,0.07)',
        border: 'rgba(251,191,36,0.4)',
      },
      failed: {
        title:  'Connection error — message may not have been delivered',
        col:    '#f87171',
        bg:     'rgba(248,113,113,0.07)',
        border: 'rgba(248,113,113,0.4)',
      },
      timeout: {
        title:  'No response from server — message may not have arrived',
        col:    '#fb923c',
        bg:     'rgba(251,146,60,0.07)',
        border: 'rgba(251,146,60,0.4)',
      },
    }[type] ?? {
      title: 'Something went wrong sending your message',
      col: '#f87171', bg: 'rgba(248,113,113,0.07)', border: 'rgba(248,113,113,0.4)',
    };

    const banner = document.createElement('div');
    banner.id = 'jv4-send-fail-banner';
    banner.style.cssText = `background:${cfg.bg};border-color:${cfg.border};`;
    banner.innerHTML = `
      <button class="jv4-sfb-close" title="Dismiss">✕</button>
      <div class="jv4-sfb-header" style="color:${cfg.col};">
        ${SVG_WARN}&nbsp;${cfg.title}
      </div>
      <div class="jv4-sfb-airplane">
        ${SVG_AIRPLANE}
        <span>
          <strong>Quick bypass:</strong> enable <strong>Airplane Mode</strong> for ~5 seconds,
          then turn it off and reconnect your data or Wi-Fi. This refreshes your network session
          and clears the rate-limit — messages should go through immediately after.
        </span>
      </div>
      ${retryPayload ? `<button class="jv4-sfb-retry">\u21ba Retry message</button>` : ''}
    `;

    barEl.insertAdjacentElement('beforebegin', banner);

    // Retry: re-send the original payload without adding another chat bubble
    if (retryPayload) {
      banner.querySelector('.jv4-sfb-retry')?.addEventListener('click', () => {
        banner.remove();
        chatNet.send(retryPayload);
      });
    }

    // Auto-dismiss after 20 s; also dismiss on close button (always, regardless of retry)
    const autoDismiss = setTimeout(() => banner.remove(), 20000);
    banner.querySelector('.jv4-sfb-close')?.addEventListener('click', () => {
      clearTimeout(autoDismiss);
      banner.remove();
    });
  }

  function _markMessageDelivered(msgId) {
    if (!msgId) return;
    const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
    if (!bubble) return;
    const status = bubble.querySelector('.jv4-delivery-status');
    if (!status) return;
    status.textContent = '✓ Delivered';
    status.className = 'jv4-delivery-status jv4-delivery-ok';
    setTimeout(() => {
      status.style.transition = 'opacity 0.5s ease';
      status.style.opacity = '0';
      setTimeout(() => status.remove(), 500);
    }, 2500);
  }

  async function _sendReaction(reactionTo, emoji) {
    const payload = { v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'reaction', text: emoji, reactionTo, ts: Date.now() };
    try {
      const enc = await pskEncrypt(payload);
      if (enc) {
        enc.psk  = true;
        enc.room = _p2pGetRoom();
        // enc.nick intentionally omitted from outer envelope
        chatNet.send(enc);
      } else {
        chatNet.send(payload); // fallback to plaintext if encryption fails
      }
    } catch {
      chatNet.send(payload); // fallback to plaintext if encryption fails
    }
    chatRender.applyReaction(reactionTo, emoji, _p2pGetPeerId(), true);
  }

  function _maybeNotifyReply(msg) {
    if (!msg.replyTo || msg.replyTo.peer !== _p2pGetPeerId()) return;
    const nick = (msg.nick || 'Someone').slice(0, 20);
    chatRender.systemMsg(`💬 ${nick} replied to your message`);
    const body = `${nick} replied: ${msg.text.slice(0, 80)}`;
    if (Notification?.permission === 'granted') {
      new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
    } else if (Notification?.permission === 'default') {
      Notification.requestPermission().then(p => {
        if (p === 'granted') new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
      });
    }
  }

  // ─── EMOJI PICKER (input area) ────────────────────────────────────────────────

  // ─── BLOCKED USERS PANEL ─────────────────────────────────────────────────────


  function _esc(str) {
    return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
  }

  GM_addStyle(`
    #jv4-p2p-modal .ms2-modal-body { padding: 0; display: flex; flex-direction: column; }
    #jv4-p2p-list {
      flex: 1; overflow-y: auto; padding: 10px 12px; display: flex;
      flex-direction: column; gap: 6px; min-height: 220px; max-height: 320px;
    }
    #jv4-p2p-list::-webkit-scrollbar { width: 3px; }
    #jv4-p2p-list::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.35); border-radius: 3px; }
    .jv4-p2p-bubble {
      max-width: 82%; padding: 6px 10px; border-radius: 10px; font-size: 12.5px;
      line-height: 1.5; word-break: break-word; animation: ms2-up 0.15s ease;
    }
    .jv4-p2p-bubble-me {
      align-self: flex-end; background: rgba(139,92,246,0.22);
      border: 1px solid rgba(139,92,246,0.4); color: #e2d9f3;
    }
    .jv4-delivery-status { display: block; font-size: 10px; text-align: right; margin-top: 2px; }
    .jv4-delivery-pending { color: rgba(167,139,250,0.45); }
    .jv4-delivery-ok { color: #10b981; }
    .jv4-p2p-bubble-other {
      align-self: flex-start; background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.1); color: #d1d5db;
    }
    .jv4-p2p-bubble-system {
      align-self: center; font-size: 11px; color: #6b7280;
      background: transparent; border: none; font-style: italic; padding: 2px 0;
    }
    .jv4-p2p-bubble-admin {
      border-left: 2px solid rgba(234,179,8,0.65) !important;
      background: rgba(234,179,8,0.04) !important;
    }
    .jv4-p2p-meta { display: flex; align-items: baseline; gap: 5px; margin-bottom: 2px; flex-wrap: wrap; }
    .jv4-p2p-nick { font-size: 11px; font-weight: 600; color: #a78bfa; }
    .jv4-p2p-time { font-size: 10px; color: #6b7280; }
    .jv4-p2p-text { margin: 0; }
    .jv4-p2p-mute {
      font-size: 10px; color: #6b7280; background: none; border: none;
      cursor: pointer; padding: 1px 4px; border-radius: 4px; margin-left: 2px;
      transition: color 0.1s, background 0.1s;
    }
    .jv4-p2p-mute:hover { background: rgba(255,255,255,0.08); color: #d1d5db; }
    .jv4-p2p-quote {
      background: rgba(139,92,246,0.1); border-left: 2px solid #8b5cf6;
      border-radius: 0 4px 4px 0; padding: 3px 7px; margin-bottom: 5px;
      font-size: 10px; color: #a78bfa; max-height: 36px; overflow: hidden;
      text-overflow: ellipsis; white-space: nowrap;
    }
    .jv4-p2p-reactions { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
    .jv4-p2p-rxn {
      background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
      border-radius: 10px; padding: 1px 6px; font-size: 12px; cursor: pointer;
      display: flex; align-items: center; gap: 3px; transition: background 0.1s;
      color: #d1d5db; font-family: system-ui,sans-serif;
    }
    .jv4-p2p-rxn:hover { background: rgba(139,92,246,0.18); }
    .jv4-p2p-rxn.mine  { border-color: rgba(139,92,246,0.55); background: rgba(139,92,246,0.15); }
    .jv4-p2p-rxn-count { font-size: 10px; color: #9ca3af; }
    .jv4-rxn-picker {
      position: absolute; background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
      border-radius: 24px; padding: 4px 8px; display: flex; gap: 2px;
      z-index: 10000060; box-shadow: 0 4px 14px rgba(0,0,0,0.55);
      animation: ms2-up 0.13s ease;
    }
    .jv4-rxn-pick-btn {
      font-size: 16px; background: none; border: none; cursor: pointer;
      border-radius: 50%; padding: 3px; transition: background 0.1s;
    }
    .jv4-rxn-pick-btn:hover { background: rgba(139,92,246,0.2); }
    #jv4-emoji-picker {
      position: absolute; bottom: 100%; right: 0; margin-bottom: 6px;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
      border-radius: 10px; padding: 8px; display: grid; grid-template-columns: repeat(6,1fr);
      gap: 3px; z-index: 10000050; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
      animation: ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
    }
    .jv4-emoji-btn {
      font-size: 18px; background: none; border: none; cursor: pointer;
      border-radius: 6px; padding: 3px; line-height: 1;
      transition: background 0.1s; display: flex; align-items: center; justify-content: center;
    }
    .jv4-emoji-btn:hover { background: rgba(139,92,246,0.2); }
    #jv4-p2p-pinned-bar {
      display: none; padding: 5px 12px; background: rgba(139,92,246,0.12);
      border-bottom: 1px solid rgba(139,92,246,0.25); font-size: 11px;
      color: #c4b5fd; cursor: default; flex-shrink: 0;
    }
    #jv4-p2p-pinned-bar.visible { display: flex; align-items: center; gap: 6px; }
    #jv4-p2p-typing { min-height: 18px; padding: 2px 12px 0; font-size: 10px; color: #6b7280; font-style: italic; flex-shrink: 0; }
    #jv4-p2p-online { font-size: 10px; color: #10b981; margin-left: 8px; }
    #jv4-p2p-reply-strip {
      display: none; align-items: center; gap: 6px;
      padding: 4px 12px; background: rgba(139,92,246,0.08);
      border-top: 1px solid rgba(139,92,246,0.2); font-size: 11px; color: #a78bfa; flex-shrink: 0;
    }
    #jv4-p2p-reply-strip.visible { display: flex; }
    #jv4-p2p-reply-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    #jv4-p2p-reply-cancel { background: none; border: none; color: #6b7280; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
    #jv4-p2p-reply-cancel:hover { color: #ef4444; }
    #jv4-p2p-bar {
      display: flex; align-items: center; gap: 5px; padding: 6px 8px;
      border-top: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
    }
    #jv4-p2p-input {
      touch-action: manipulation;
      -webkit-user-select: text;
      user-select: text;
      -webkit-tap-highlight-color: rgba(139,92,246,0.15);
    }
    #jv4-p2p-room-toggle {
      font-size: 11px; cursor: pointer; padding: 3px 8px;
      border-radius: 6px; border: 1px solid rgba(6,182,212,0.4);
      background: rgba(6,182,212,0.1); color: #67e8f9;
      white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
      display: inline-flex; align-items: center; gap: 5px;
      max-width: 130px; overflow: hidden;
    }
    #jv4-p2p-room-toggle:hover { background: rgba(6,182,212,0.2); }
    #jv4-p2p-room-toggle .jv4-toggle-label {
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    .jv4-toggle-avatar {
      width: 18px; height: 18px; border-radius: 50%;
      object-fit: cover; flex-shrink: 0;
      border: 1px solid rgba(6,182,212,0.5);
    }
    #jv4-p2p-status {
      font-size: 10.5px; color: #6b7280; padding: 3px 12px 0;
      display: flex; align-items: center; gap: 5px;
    }
    .jv4-p2p-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
    .jv4-p2p-dot-on   { background: #10b981; box-shadow: 0 0 4px #10b981; }
    .jv4-p2p-dot-off  { background: #6b7280; }
    .jv4-p2p-dot-warn { background: #f59e0b; box-shadow: 0 0 4px #f59e0b; animation: jv4-dot-pulse 1.2s ease-in-out infinite; }
    @keyframes jv4-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
    #jv4-emoji-open {
      opacity: 1 !important; background: none; border: none;
      cursor: pointer; padding: 4px 6px; border-radius: 6px; flex-shrink: 0;
      line-height: 1; color: #9ca3af; transition: background 0.1s, color 0.1s;
      display: flex; align-items: center;
    }
    #jv4-emoji-open:hover { background: rgba(139,92,246,0.18); color: #c4b5fd; }
    #jv4-admin-badge {
      display: none; align-items: center; gap: 3px; font-size: 10px;
      background: rgba(234,179,8,0.14); border: 1px solid rgba(234,179,8,0.45);
      color: #fbbf24; border-radius: 10px; padding: 1px 8px;
      margin-left: 8px; font-weight: 700; letter-spacing: .3px;
    }
    #jv4-admin-badge.visible { display: inline-flex; }
    .jv4-adm-btn {
      font-size: 10.5px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
      border: 1px solid rgba(var(--adm-col),0.4); color: rgb(var(--adm-col));
      background: rgba(var(--adm-col),0.08); transition: background 0.15s;
      white-space: nowrap;
    }
    .jv4-adm-btn:hover  { background: rgba(var(--adm-col),0.2); }
    .jv4-adm-btn.active { background: rgba(var(--adm-col),0.25); outline: 1px solid rgb(var(--adm-col)); }
    #jv4-p2p-send:disabled { opacity: 0.6; cursor: not-allowed; }
    #jv4-chat-tip {
      display: none; flex-direction: column; gap: 6px;
      margin: 8px 12px 0; padding: 10px 12px;
      background: rgba(6,182,212,0.08); border: 1px solid rgba(6,182,212,0.35);
      border-radius: 10px; font-size: 11.5px; color: #bae6fd;
      animation: ms2-up 0.18s cubic-bezier(0.16,1,0.3,1);
      position: relative;
    }
    #jv4-chat-tip.visible { display: flex; }
    #jv4-chat-tip-title {
      font-weight: 700; font-size: 12px; color: #67e8f9;
      display: flex; align-items: center; gap: 5px;
    }
    #jv4-chat-tip-close {
      position: absolute; top: 6px; right: 8px;
      background: none; border: none; color: #6b7280;
      font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
    }
    #jv4-chat-tip-close:hover { color: #ef4444; }
    #jv4-chat-tip p { margin: 0; line-height: 1.55; }
    #jv4-chat-tip strong { color: #e0f2fe; }
    #jv4-chat-tip-airplane {
      display: flex; align-items: flex-start; gap: 6px;
      padding: 7px 9px; margin-top: 2px;
      background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3);
      border-radius: 8px; font-size: 11px; color: #c4b5fd;
    }
    #jv4-chat-tip-airplane svg { flex-shrink: 0; margin-top: 1px; }
    #jv4-chat-info-btn {
      background: none; border: 1px solid rgba(6,182,212,0.35);
      border-radius: 50%; width: 20px; height: 20px;
      display: inline-flex; align-items: center; justify-content: center;
      cursor: pointer; color: #67e8f9; font-size: 11px; font-weight: 700;
      line-height: 1; margin-left: 6px; flex-shrink: 0;
      transition: background 0.15s, border-color 0.15s;
    }
    #jv4-chat-info-btn:hover { background: rgba(6,182,212,0.15); border-color: rgba(6,182,212,0.6); }
    #jv4-chat-info-btn.active { background: rgba(6,182,212,0.2); border-color: #67e8f9; }
    #jv4-send-fail-banner {
      display: flex; flex-direction: column; gap: 7px;
      margin: 0 8px 6px; padding: 9px 28px 9px 11px;
      border-radius: 10px; border: 1px solid;
      font-size: 12px; line-height: 1.5;
      animation: jv4-sfb-in 0.22s ease;
      position: relative;
    }
    @keyframes jv4-sfb-in {
      from { opacity: 0; transform: translateY(5px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    #jv4-send-fail-banner .jv4-sfb-header {
      display: flex; align-items: center; gap: 6px;
      font-weight: 700; font-size: 12px;
    }
    #jv4-send-fail-banner .jv4-sfb-close {
      position: absolute; top: 7px; right: 8px;
      background: none; border: none; color: #6b7280;
      font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
    }
    #jv4-send-fail-banner .jv4-sfb-close:hover { color: #ef4444; }
    #jv4-send-fail-banner .jv4-sfb-airplane {
      display: flex; align-items: flex-start; gap: 7px;
      padding: 6px 8px; border-radius: 7px;
      background: rgba(139,92,246,0.12); border: 1px solid rgba(139,92,246,0.3);
      color: #c4b5fd;
    }
    #jv4-send-fail-banner .jv4-sfb-airplane svg { flex-shrink: 0; margin-top: 2px; }
    #jv4-send-fail-banner .jv4-sfb-retry {
      align-self: flex-start; padding: 4px 12px; border-radius: 6px;
      border: 1px solid rgba(167,139,250,0.5); background: rgba(139,92,246,0.15);
      color: #c4b5fd; font-size: 11px; font-weight: 600; cursor: pointer;
      transition: background 0.15s;
    }
    #jv4-send-fail-banner .jv4-sfb-retry:hover { background: rgba(139,92,246,0.3); }
  `);

  function _p2pExportHistory() {
    const hist = _p2pGetHistory();
    if (!hist.length) { topToast('No chat history to export'); return; }
    const lines = hist.map(m => {
      const t = new Date(m.ts).toLocaleString();
      return `[${t}] ${(m.nick||'Anonymous').slice(0,24)}#${(m.peer||'').slice(0,4)}: ${m.text}`;
    });
    const blob = new Blob([lines.join('\n')],{type:'text/plain'});
    const url  = URL.createObjectURL(blob);
    const a    = document.createElement('a');
    a.href=url; a.download=`jv4-chat-${new Date().toISOString().slice(0,10)}.txt`;
    a.click(); URL.revokeObjectURL(url);
    topToast('Chat exported');
  }

  function _p2pShowBlockedPanel() {
    const existing = document.getElementById('jv4-blocked-panel');
    if (existing) { existing.remove(); return; }

    const blocked = [..._p2pGetBlocked()];
    const panel = document.createElement('div');
    panel.id = 'jv4-blocked-panel';
    panel.style.cssText = `
      position:absolute; bottom:100%; left:0; right:0; margin-bottom:4px;
      background:#1a1625; border:1px solid rgba(139,92,246,0.45);
      border-radius:10px; padding:10px 12px; z-index:10000060;
      box-shadow:0 4px 20px rgba(0,0,0,0.6);
      animation:ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
      max-height:200px; overflow-y:auto;
    `;

    if (!blocked.length) {
      panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
    } else {
      panel.innerHTML = `<div style="font-size:10.5px;color:#a78bfa;font-weight:600;margin-bottom:6px;">🔇 Muted users — tap to unmute</div>`;
      blocked.forEach(peerId => {
        const row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
        row.innerHTML = `
          <span style="font-size:11px;color:#d1d5db;flex:1;font-family:monospace;">${_esc(peerId.slice(0,12))}…</span>
          <button style="font-size:10px;color:#10b981;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;padding:2px 7px;cursor:pointer;">Unmute</button>
        `;
        row.querySelector('button').addEventListener('click', () => {
          _p2pUnblockPeer(peerId);
          row.remove();
          _p2pSystemMsg(`✅ Unmuted ${peerId.slice(0,8)}…`);
          
          _p2pSyncMuteButtons();
          if (!panel.querySelector('div[style]')) {
            panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
          }
        });
        panel.appendChild(row);
      });
    }

    const dismiss = ev => {
      if (!panel.contains(ev.target) && ev.target.id!=='jv4-muted-btn') {
        panel.remove(); document.removeEventListener('click',dismiss,true);
      }
    };
    setTimeout(()=>document.addEventListener('click',dismiss,true),0);

    const bar = document.getElementById('jv4-p2p-bar');
    if (bar) { bar.style.position='relative'; bar.appendChild(panel); }
  }

  function _p2pSyncMuteButtons() {
    const blocked = _p2pGetBlocked();
    const list = _cs.listEl || document.getElementById('jv4-p2p-list');
    if (!list) return;
    list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
      btn.textContent = blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
    });
  }

  function _p2pToggleEmojiPicker(input, wrapEl) {
    const existing = document.getElementById('jv4-emoji-picker');
    if (existing) { existing.remove(); return; }
    const picker = document.createElement('div');
    picker.id = 'jv4-emoji-picker';
    P2P_CHAT_EMOJIS.forEach(e => {
      const btn = document.createElement('button');
      btn.className='jv4-emoji-btn'; btn.textContent=e;
      btn.addEventListener('click', ev => {
        ev.stopPropagation();
        const s = input.selectionStart ?? input.value.length;
        input.value = input.value.slice(0,s)+e+input.value.slice(s);
        input.focus(); input.selectionStart=input.selectionEnd=s+e.length;
      });
      picker.appendChild(btn);
    });
    wrapEl.style.position='relative';
    wrapEl.appendChild(picker);
    const dismiss = ev => {
      if (!picker.contains(ev.target)&&ev.target.id!=='jv4-emoji-open') {
        picker.remove(); document.removeEventListener('click',dismiss,true);
      }
    };
    setTimeout(()=>document.addEventListener('click',dismiss,true),10);
  }


  function _buildChatTipHTML() {
    const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;
    const SVG_CLOCK = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
    return `
      <div id="jv4-chat-tip-title">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
        Connection &amp; Chat Tips
      </div>
      <button id="jv4-chat-tip-close" title="Dismiss">✕</button>
      <p>
        ${SVG_CLOCK} Messages are relayed via <strong>ntfy.sh</strong> and fetched every
        <strong>8 seconds</strong> — so there's a short delay before others see what you sent.
        Your own bubble shows <strong>✓ Delivered</strong> in green once the server confirms it went through.
        If it stays on "Sending…", something blocked the send — see below.
      </p>
      <p>
        <strong>Message not going through?</strong> A red banner appears with a one-tap
        <strong>Retry</strong> button. Most failures are temporary — retry first before anything else.
        If retries keep failing, the relay may be rate-limiting your connection (HTTP 429).
      </p>
      <div id="jv4-chat-tip-airplane">
        ${SVG_AIRPLANE}
        <span>
          <strong>Fastest rate-limit fix:</strong> toggle <strong>Airplane Mode</strong> on for ~5 s,
          then off again. This resets your carrier session and clears the limit immediately —
          messages go through right after reconnecting.
        </span>
      </div>
      <p style="margin-top:8px;">
        <strong>Rooms</strong> — tap <strong>Global</strong> or <strong>Char Room</strong> to switch.
        Character rooms are isolated per character page; only people viewing the same character see them.
        Global is site-wide and always active.
      </p>
      <p>
        <strong>Mute &amp; report</strong> — hit <em>mute</em> on any bubble to hide that user locally (only you see the change).
        Use ⚑ to report to admins. Your blocked list lives under the <strong>🔇 Muted</strong> button.
      </p>
      <div style="margin-top:10px;padding:9px 11px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.35);border-radius:8px;font-size:11.5px;line-height:1.6;color:#c4b5fd;">
        💜 <strong>Enjoying Community Chat?</strong> Share JanitorV5 with other roleplayers — drop the
        GreasyFork link on TikTok, X, Discord, or Reddit. A short screen recording or honest review
        goes a long way. The bigger the community, the better Chat gets for everyone.
      </div>
    `;
  }

  function _setupChatTip(modal) {
    const tip     = document.createElement('div');
    tip.id        = 'jv4-chat-tip';
    tip.innerHTML = _buildChatTipHTML();

    const pinnedBar = modal.querySelector('#jv4-p2p-pinned-bar');
    if (pinnedBar) pinnedBar.after(tip);

    const closeBtn  = tip.querySelector('#jv4-chat-tip-close');
    const infoBtn   = modal.querySelector('#jv4-chat-info-btn');

    const hideTip = () => {
      tip.classList.remove('visible');
      if (infoBtn) infoBtn.classList.remove('active');
    };

    const showTip = () => {
      tip.classList.add('visible');
      if (infoBtn) infoBtn.classList.add('active');
      tip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    };

    closeBtn?.addEventListener('click', () => {
      hideTip();
      try { GM_setValue(P2P_GM_TIP_SEEN, 'true'); } catch {}
    });

    infoBtn?.addEventListener('click', () => {
      if (tip.classList.contains('visible')) { hideTip(); } else { showTip(); }
    });

    const alreadySeen = (() => { try { return GM_getValue(P2P_GM_TIP_SEEN, ''); } catch { return ''; } })();
    if (!alreadySeen) {
      setTimeout(() => showTip(), 400);
  }
  }

  async function _openP2PChatModal() {

    chatStore.blocked = _p2pGetBlocked();

    chatStore.open       = true;
    chatStore.messages   = [];
    chatStore.seenMsgIds = _loadPersistedSeenIds(); // load persisted IDs so inter-session messages don't re-appear
    chatStore.seenNtfyIds = new Set();
    chatStore.replyingTo = null;
    chatStore.onlineMap  = new Map(); // clear stale presence from previous session
    // Reset lastEventId on every fresh open so we don't carry stale IDs from a
    // different room session into the new one.
    chatNet.lastEventId  = null;

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    addEscapeClose(backdrop);

    const charId  = _p2pGetCharId();
    const canChar = !!charId;

    // If the stored room is 'char' but we're not on a character page any more,
    // silently reset to 'global' so the topic doesn't collapse and mix rooms.
    let room = _p2pGetRoom();
    if (room === 'char' && !canChar) {
      room = 'global';
      try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
    }

    const charDisplayName = canChar ? (_p2pGetCharName() || 'Char Room') : 'Global';
    const charAvatarSrc   = canChar && room === 'char' ? (_p2pGetCharAvatar() || '') : '';
    const roomLabel = room === 'char' && canChar ? charDisplayName : 'Global';

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.id = 'jv4-p2p-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.style.maxWidth = 'min(400px, 100vw - 16px)';
    modal.style.width = '100%';

    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title" style="display:flex;align-items:center;flex:1;min-width:0;gap:6px;">
          ${SVG_CHAT} Community Chat
          <span style="font-weight:400;color:#6b7280;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">· ${_esc(_p2pGetNickname())}</span>
          <span id="jv4-admin-badge">👑 ADMIN</span>
          ${_makeInfoBtn('p2p-e2e')}
        </div>
        <button class="ms2-modal-close" aria-label="Close">×</button>
      </div>
      <div id="jv4-p2p-status" style="padding:4px 12px 3px;">
        <div id="jv4-p2p-dot" class="jv4-p2p-dot jv4-p2p-dot-off"></div>
        <span id="jv4-p2p-status-label">Connecting…</span>
        <span id="jv4-p2p-online"></span>
      </div>
      <div style="padding:2px 12px 4px;font-size:10px;color:#6b7280;user-select:all;">
        My ID: <span id="jv4-my-peerid"
          style="color:#8b5cf6;cursor:pointer;font-family:monospace;"
          title="Tap to copy">${_esc(_p2pGetPeerId())}</span>
      </div>
      <div id="jv4-p2p-pinned-bar"></div>
      <div class="ms2-modal-body">
        <div id="jv4-p2p-list"></div>
        <div id="jv4-p2p-typing"></div>
        <div id="jv4-p2p-reply-strip">
          <span id="jv4-p2p-reply-text"></span>
          <button id="jv4-p2p-reply-cancel" title="Cancel reply">✕</button>
        </div>
        <div id="jv4-p2p-bar">
          ${canChar ? `<button id="jv4-p2p-room-toggle">${charAvatarSrc && room === 'char' ? `<img class="jv4-toggle-avatar" src="${_esc(charAvatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(roomLabel)}</span></button>` : ''}
          <button id="jv4-emoji-open" title="Emoji">
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                 style="vertical-align:middle;pointer-events:none">
              <circle cx="12" cy="12" r="10"/>
              <path d="M8 14s1.5 2 4 2 4-2 4-2"/>
              <line x1="9" y1="9" x2="9.01" y2="9"/>
              <line x1="15" y1="9" x2="15.01" y2="9"/>
            </svg>
          </button>
          <input type="text" id="jv4-p2p-input" class="ms2-input"
                 placeholder="Say something…" maxlength="800" autocomplete="off"
                 inputmode="text" enterkeyhint="send" autocapitalize="sentences"
                 spellcheck="false">
          <button id="jv4-p2p-send" class="ms2-btn-action ms2-btn-generate"
                  style="padding:6px 12px;">Send</button>
        </div>
      </div>
      <div class="ms2-modal-footer" style="gap:6px;padding:8px 14px;">
        <button id="jv4-p2p-nick-btn"    class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Nick</button>
        <button id="jv4-p2p-export-btn"  class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Export</button>
        <button id="jv4-muted-btn"       class="ms2-btn-action ms2-btn-copy" style="font-size:11px;" title="View/manage muted users">🔇 Muted</button>
        <span style="flex:1"></span>
        <button class="ms2-modal-close ms2-btn-action" style="font-size:11px;padding:5px 12px;">Close</button>
      </div>
    `;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    chatStore.listEl = modal.querySelector('#jv4-p2p-list');

    const visHandler = () => {
      if (!document.hidden && chatStore.open) {
        // Page came back to foreground — reconnect immediately so the user
        // doesn't have to wait for the next poll or stale-timer to fire
        chatNet.backoffMs = P2P_BACKOFF_MIN;
        chatNet.connect().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));
      }
    };
    document.addEventListener('visibilitychange', visHandler);

    // Show a visual cue when the device loses network so the user understands why
    // messages aren't coming through (instead of just a stuck "Reconnecting" dot)
    const offlineHandler = () => chatRender.updateStatus('reconnecting');
    window.addEventListener('offline', offlineHandler);

    const doClose = () => {
      chatStore.open   = false;
      chatStore.listEl = null;
      chatNet.disconnect();
      document.removeEventListener('visibilitychange', visHandler);
      window.removeEventListener('offline', offlineHandler);
      _p2pCleanupDismissListeners(); // prevent leak from any open pickers/panels
      backdrop.remove();
    };
    modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', doClose));
    backdrop.addEventListener('click', e => { if (e.target === backdrop) doClose(); });

    modal.querySelector('#jv4-my-peerid')?.addEventListener('click', function () {
      navigator.clipboard.writeText(this.textContent.trim())
        .then(()  => topToast('Peer ID copied!'))
        .catch(() => topToast('Tap and hold to copy manually'));
    });

    chatStore.listEl.addEventListener('scroll', () => {
      const list = chatStore.listEl;
      if (!list) return;
      const near = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
      chatStore.atBottom = near;
      if (near) chatRender._hideNewMsgBadge();
    }, { passive: true });

    chatRender.updatePinnedBar();
    _setupChatTip(modal);

    const myPeer = _p2pGetPeerId();
    // ── History filter: strict per-room — no cross-bleed ─────────────────────
    // Critical: char room must NEVER show untagged (old-format) global messages.
    // The || !m.room fallback is only safe for the global room where untagged
    // messages are legacy global chat. In char rooms it caused global history
    // to appear after closing and reopening the modal.
    const _curRoom = _p2pGetRoom();
    const hist = _p2pGetHistory().filter(m => {
      if (_curRoom === 'char') return m.room === 'char';
      return m.room === 'global' || !m.room; // !m.room = backward compat for pre-room-tag messages
    });
    for (const msg of hist) {
      if (msg.msgId) chatStore.seenMsgIds.add(msg.msgId);
      chatStore.messages.push(msg);
      chatRender.appendBubble(msg, msg.peer === myPeer);
    }

    chatRender.systemMsg('🔐 End-to-end encrypted · Relay sees your connection IP but zero message content · IPs never shared with other users · msgs auto-delete ~30 min');

    const list = chatStore.listEl;
    if (list) list.scrollTop = list.scrollHeight;

    _p2pFetchVerified();

    chatNet.connect().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));

    const input   = modal.querySelector('#jv4-p2p-input');
    const sendBtn = modal.querySelector('#jv4-p2p-send');
    const barEl   = modal.querySelector('#jv4-p2p-bar');

    const doSend = () => {
      const txt = input.value.trim();
      if (!txt) return;
      _p2pSendMessage(txt, input, sendBtn);
      input.value = '';
      input.focus();
      document.getElementById('jv4-emoji-picker')?.remove();
    };

    sendBtn.addEventListener('click', doSend);
    input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } });

    input.addEventListener('input', () => {
      if (chatNet.typingSendTimer) clearTimeout(chatNet.typingSendTimer);
      chatNet.typingSendTimer = setTimeout(() => {
        if (input.value.trim()) chatNet.sendTyping();
        chatNet.typingSendTimer = null;
      }, 1200);
    });

    modal.querySelector('#jv4-p2p-reply-cancel')?.addEventListener('click', () => {
      chatStore.replyingTo = null;
      modal.querySelector('#jv4-p2p-reply-strip')?.classList.remove('visible');
    });

    modal.querySelector('#jv4-emoji-open')?.addEventListener('click', e => {
      e.stopPropagation(); _p2pToggleEmojiPicker(input, barEl);
    });

    modal.querySelector('#jv4-p2p-export-btn')?.addEventListener('click', _p2pExportHistory);
    modal.querySelector('#jv4-muted-btn')?.addEventListener('click', e => { e.stopPropagation(); _p2pShowBlockedPanel(); });

    if (canChar) {
      modal.querySelector('#jv4-p2p-room-toggle')?.addEventListener('click', () => {
        const cur  = _p2pGetRoom();
        const next = cur === 'global' ? 'char' : 'global';
        GM_setValue(P2P_GM_ROOM, next);
        
        const lEl = modal.querySelector('#jv4-p2p-list');
        if (lEl) {
          lEl.innerHTML = '';
          chatStore.messages    = [];
          chatStore.seenMsgIds  = new Set();
          chatStore.seenNtfyIds = new Set(); // clear so new room IDs aren't filtered
        }
        // Reset lastEventId so the poll fetches the last 30 min of the NEW room
        chatNet.lastEventId = null;
        chatNet.connect().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));

        // Load history for the new room
        const nextHist = _p2pGetHistory().filter(m =>
          next === 'char'
            ? m.room === 'char'
            : (m.room === 'global' || !m.room)
        );
        for (const m of nextHist) {
          if (m.msgId) chatStore.seenMsgIds.add(m.msgId);
          chatStore.messages.push(m);
          chatRender.appendBubble(m, m.peer === myPeer);
        }
        const switchedCharName = next === 'char' ? (_p2pGetCharName() || 'Character') : null;
        chatRender.systemMsg(`Switched to ${switchedCharName ? switchedCharName + '\'s room' : 'Global room'}`);
        const toggle = modal.querySelector('#jv4-p2p-room-toggle');
        if (toggle) {
          const label = next === 'char' ? (switchedCharName || 'Char Room') : 'Global';
          const avatarSrc = next === 'char' ? (_p2pGetCharAvatar() || '') : '';
          toggle.innerHTML = `${avatarSrc ? `<img class="jv4-toggle-avatar" src="${_esc(avatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(label)}</span>`;
        }
      });
    }

    modal.querySelector('#jv4-p2p-nick-btn')?.addEventListener('click', () => {
      // window.prompt() is silently blocked on many mobile browsers — use a proper modal instead
      const existing = document.getElementById('jv5-nick-overlay');
      if (existing) existing.remove();

      const cur = _p2pGetNickname();
      const ov  = document.createElement('div');
      ov.id = 'jv5-nick-overlay';
      ov.style.cssText = 'position:fixed;inset:0;z-index:10000060;background:rgba(0,0,0,0.65);display:flex;align-items:center;justify-content:center;';
      ov.innerHTML = `
        <div style="background:#13131f;border:1px solid rgba(139,92,246,0.5);border-radius:14px;
          padding:18px 16px;width:min(300px,calc(100vw - 32px));box-shadow:0 12px 36px rgba(0,0,0,0.75);
          font-family:system-ui,sans-serif;">
          <div style="font-size:13px;font-weight:700;color:#c4b5fd;margin-bottom:10px;">✏️ Set Nickname</div>
          <input id="jv5-nick-input" type="text" maxlength="24"
            value="${_esc(cur)}"
            placeholder="Enter nickname (max 24 chars)"
            style="width:100%;box-sizing:border-box;padding:9px 11px;background:#0d0d1a;
              border:1px solid rgba(139,92,246,0.4);border-radius:8px;color:#e2e8f0;
              font-size:13px;outline:none;margin-bottom:10px;">
          <div style="display:flex;gap:8px;">
            <button id="jv5-nick-save" style="flex:1;padding:8px;font-size:12px;font-weight:600;
              background:linear-gradient(135deg,#7c3aed,#6d28d9);border:none;border-radius:8px;
              color:#fff;cursor:pointer;">Save</button>
            <button id="jv5-nick-cancel" style="flex:1;padding:8px;font-size:12px;
              background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);
              border-radius:8px;color:#9ca3af;cursor:pointer;">Cancel</button>
          </div>
        </div>`;

      document.body.appendChild(ov);
      setTimeout(() => ov.querySelector('#jv5-nick-input')?.focus(), 80);

      const _doSave = () => {
        // _sanitizeNick strips control chars and HTML injection chars at storage time,
        // not just at display time — prevents malicious bytes from reaching GM storage
        // and propagating to all other users' rendered bubbles.
        const val = _sanitizeNick(ov.querySelector('#jv5-nick-input')?.value || '');
        GM_setValue(P2P_GM_NICKNAME, val);
        topToast(`Nickname set to "${val}"`);
        const sub = modal.querySelector('.ms2-modal-title span');
        if (sub) sub.textContent = `· ${val}`;
        ov.remove();
      };
      ov.querySelector('#jv5-nick-save').addEventListener('click', _doSave);
      ov.querySelector('#jv5-nick-cancel').addEventListener('click', () => ov.remove());
      ov.querySelector('#jv5-nick-input').addEventListener('keydown', e => { if (e.key === 'Enter') _doSave(); if (e.key === 'Escape') ov.remove(); });
      ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
    });

    if (!('ontouchstart' in window)) {
      // Desktop: focus immediately so the user can start typing
      input.focus();
    } else {
      // Mobile: do NOT auto-focus on open (it would pop the keyboard unexpectedly),
      // but DO guarantee that tapping the input box shows the keyboard.
      // Some Android/iOS WebView hosts swallow the native focus event when the
      // tap goes through a stacked modal — the fixes below re-assert it.
      input.addEventListener('touchstart', e => {
        // Stop the modal/backdrop from stealing this touch before focus fires
        e.stopPropagation();
      }, { passive: true });

      input.addEventListener('touchend', e => {
        e.stopPropagation();
        // A small delay lets the browser settle the touch before we force-focus,
        // which is the reliable way to open the soft keyboard on iOS/Android.
        setTimeout(() => {
          input.focus();
          // Scroll the input into view in case the keyboard pushed content up
          input.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
        }, 30);
      }, { passive: true });

    _setupAdminUnlock(modal, input);
  }
  }

  function _buildAdminBar(modal, inputEl) {
    const adminBar = document.createElement('div');
    adminBar.id = 'jv4-admin-bar';
    adminBar.style.cssText = 'display:flex;flex-direction:column;gap:5px;padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.25);background:rgba(234,179,8,0.04);';
    adminBar.innerHTML = `
      <div style="font-size:10px;color:rgba(234,179,8,0.8);font-weight:700;letter-spacing:.5px;margin-bottom:1px;">
        👑 ADMIN COMMANDS
      </div>
      <div id="jv4-admin-btns" style="display:flex;flex-wrap:wrap;gap:4px;">
        <button data-cmd="pin"   class="jv4-adm-btn" style="--adm-col:139,92,246">📌 Pin</button>
        <button data-cmd="unpin" class="jv4-adm-btn jv4-adm-noarg" style="--adm-col:107,114,128">🗑 Unpin</button>
      </div>
      <div style="display:flex;gap:5px;align-items:center;">
        <input id="jv4-admin-cmd" class="ms2-input"
          placeholder="/pin your message here…"
          style="flex:1;margin:0;font-size:11px;" maxlength="300">
        <button id="jv4-admin-send"
          style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
                 font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;flex-shrink:0;">⚡ Run</button>
      </div>
    `;

    modal.querySelector('#jv4-p2p-bar').after(adminBar);

    const cmdInput = adminBar.querySelector('#jv4-admin-cmd');
    const sendBtn  = adminBar.querySelector('#jv4-admin-send');

    adminBar.querySelectorAll('.jv4-adm-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const cmd   = btn.dataset.cmd;
        const noArg = btn.classList.contains('jv4-adm-noarg');
        adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        if (noArg) {
          _p2pSendMessage('/' + cmd, inputEl, null);
          btn.classList.remove('active');
          cmdInput.value = '';
        } else {
          cmdInput.value = '/pin ';
          cmdInput.focus();
          cmdInput.setSelectionRange(cmdInput.value.length, cmdInput.value.length);
        }
      });
    });

    const runCmd = () => {
      const cmd = cmdInput.value.trim();
      if (!cmd) return;
      _p2pSendMessage(cmd.startsWith('/') ? cmd : '/pin ' + cmd, inputEl, null);
      cmdInput.value = '';
      adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
    };
    sendBtn.addEventListener('click', runCmd);
    cmdInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runCmd(); } });
  }

  function _setupAdminUnlock(modal, inputEl) {
    let tapCount = 0;
    let tapTimer = null;

    const titleEl = modal.querySelector('.ms2-modal-title');
    if (!titleEl) return;
    titleEl.style.cursor = 'default';

    const unlockPanel = document.createElement('div');
    unlockPanel.id = 'jv4-admin-unlock';
    unlockPanel.style.cssText = [
      'display:none;flex-direction:row;gap:6px;align-items:center;',
      'padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.2);',
      'background:rgba(234,179,8,0.04);'
    ].join('');
    unlockPanel.innerHTML = `
      <input id="jv4-admin-pw" type="password" class="ms2-input"
        placeholder="Admin password…"
        style="flex:1;margin:0;font-size:12px;" maxlength="80" autocomplete="off">
      <button id="jv4-admin-unlock-btn"
        style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
               font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;white-space:nowrap;flex-shrink:0;">
        🔑 Unlock
      </button>
    `;

    const statusBar = modal.querySelector('#jv4-p2p-status');
    if (statusBar) statusBar.after(unlockPanel);

    titleEl.addEventListener('click', () => {
      tapCount++;
      clearTimeout(tapTimer);
      if (tapCount >= 3) {
        tapCount = 0;
        const showing = unlockPanel.style.display !== 'none';
        unlockPanel.style.display = showing ? 'none' : 'flex';
        if (!showing) unlockPanel.querySelector('#jv4-admin-pw')?.focus();
      } else {
        tapTimer = setTimeout(() => { tapCount = 0; }, 1500);
      }
    });

    const tryUnlock = async () => {
      const pwEl = unlockPanel.querySelector('#jv4-admin-pw');
      if (!pwEl || !pwEl.value) return;
      let verified = false;

      // SECURITY FIX #4: Try PBKDF2 (v2) first; fall back to legacy SHA-256 and auto-upgrade.
      const v2stored = (() => { try { const s = GM_getValue(_GM_ADMIN_HASH_V2, ''); return s ? JSON.parse(s) : null; } catch { return null; } })();
      if (v2stored && v2stored.salt && v2stored.hash) {
        verified = await _adminVerifyPbkdf2(pwEl.value, v2stored);
      } else if (P2P_ADMIN_HASH) {
        // Legacy path — SHA-256 comparison
        const hash = await _sha256(pwEl.value);
        if (hash === P2P_ADMIN_HASH) {
          verified = true;
          // Auto-upgrade: store PBKDF2 hash for future logins
          try {
            const upgraded = await _adminPbkdf2Hash(pwEl.value);
            GM_setValue(_GM_ADMIN_HASH_V2, JSON.stringify(upgraded));
          } catch {}
        }
      }

      if (verified) {
        chatStore.isAdmin = true;
        unlockPanel.style.display = 'none';
        modal.querySelector('#jv4-admin-badge')?.classList.add('visible');
        _buildAdminBar(modal, inputEl);
        topToast('👑 Admin unlocked');
      } else {
        topToast('❌ Wrong password');
        pwEl.value = '';
        pwEl.focus();
      }
    };

    unlockPanel.querySelector('#jv4-admin-unlock-btn')?.addEventListener('click', tryUnlock);
    unlockPanel.querySelector('#jv4-admin-pw')?.addEventListener('keydown', e => {
      if (e.key === 'Enter') { e.preventDefault(); tryUnlock(); }
    });
  }

  function _openP2PConsentModal(onAccept) {
    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    addEscapeClose(backdrop);
    backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.style.maxWidth = '400px';
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_WARNING} Before you join</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body" style="padding:14px 16px;">

        <div class="ms2-tip" style="border-color:rgba(6,182,212,0.45);color:#67e8f9;margin-bottom:12px;">
          ${SVG_INFO} Community Chat relays messages through <strong>ntfy.sh</strong> (free, open-source relay).
        </div>

        <div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:8px;padding:10px 12px;margin-bottom:10px;">
          <div style="font-size:10.5px;font-weight:700;color:#6ee7b7;letter-spacing:.4px;margin-bottom:6px;">🔐 WHAT'S PROTECTED</div>
          <div style="font-size:12.5px;color:#d1d5db;line-height:1.65;">
            <strong style="color:#e5e7eb;">Your messages</strong> are end-to-end encrypted with AES-GCM-256 before leaving your browser. ntfy.sh only ever sees opaque ciphertext — no message content, no IPs hidden inside messages.<br>
            <strong style="color:#e5e7eb;">Your identity</strong> is a random anonymous peer ID — no login, no email, no real name required.<br>
            <strong style="color:#e5e7eb;">Other users</strong> see only your chosen nickname and your messages. Your IP is never shared with other users.
          </div>
        </div>

        <div style="background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.25);border-radius:8px;padding:10px 12px;margin-bottom:10px;">
          <div style="font-size:10.5px;font-weight:700;color:#fcd34d;letter-spacing:.4px;margin-bottom:6px;">⚠️ WHAT THE RELAY STILL SEES</div>
          <div style="font-size:12.5px;color:#d1d5db;line-height:1.65;">
            <strong style="color:#e5e7eb;">Your connection IP</strong> — ntfy.sh sees the IP your browser uses to connect to it. This is the same as any website visit and cannot be avoided. Use a <strong>VPN or Tor</strong> if you want to hide your real IP from the relay.<br>
            <strong style="color:#e5e7eb;">Timestamps</strong> — the relay sees when messages arrive, not what they say.<br>
            <strong style="color:#e5e7eb;">Message size</strong> — padded to 256-byte blocks, so exact length is obscured.
          </div>
        </div>

        <p style="margin:0 0 10px;font-size:11.5px;color:#6b7280;line-height:1.5;">
          Ciphertext is stored on ntfy.sh for ~30 min then auto-deleted. To remove the relay entirely, set a custom relay URL in General settings.
        </p>

        <div style="margin-top:12px;">
          <label style="font-size:12.5px;color:#c4b5fd;"><strong>Your nickname</strong></label>
          <input type="text" id="jv4-consent-nick" class="ms2-input"
                 style="margin-top:5px;" maxlength="24"
                 placeholder="Anonymous (can be changed later)"
                 value="${_esc(_p2pGetNickname())}">
        </div>
      </div>
      <div class="ms2-modal-footer" style="gap:8px;">
        <button class="ms2-modal-close ms2-btn-action ms2-btn-copy">Cancel</button>
        <button id="jv4-consent-ok" class="ms2-btn-action ms2-btn-generate">Join Community Chat</button>
      </div>
    `;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);
    modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', () => backdrop.remove()));
    modal.querySelector('#jv4-consent-ok').addEventListener('click', () => {
      const nick = _sanitizeNick(modal.querySelector('#jv4-consent-nick').value || '');
      GM_setValue(P2P_GM_NICKNAME, nick);
      GM_setValue(P2P_GM_ENABLED, 'true');
      backdrop.remove();
      onAccept();
    });
  }

  /**
   * Entry point for the Community Chat feature.
   * Shows the privacy consent modal on first use; opens the chat modal directly
   * on subsequent visits.  No-ops when the chat is already open.
   */
  function handleCommunityChat() {
    if (chatStore.open) return;
    if (GM_getValue(P2P_GM_ENABLED, 'false') !== 'true') {
      _openP2PConsentModal(_openP2PChatModal);
    } else {
      _openP2PChatModal();
    }
  }


  // ─── MODELS ────────────────────────────────────────────────────────────────

  // ── PROVIDER ENDPOINTS ───────────────────────────────────────────────────────
  const PROVIDER_ENDPOINTS = {
    openrouter:  'https://openrouter.ai/api/v1',
    openai:      'https://api.openai.com/v1',
    xai:         'https://api.x.ai/v1',
    mistral:     'https://api.mistral.ai/v1',
    groq:        'https://api.groq.com/openai/v1',
    anthropic:   'https://api.anthropic.com',
  };
  const KNOWN_EPS = Object.values(PROVIDER_ENDPOINTS);

  // ── MODEL CATALOG ─────────────────────────────────────────────────────────
  // group = displayed as <optgroup label> in the model picker
  // Works best when the chosen endpoint matches the group's provider.
  // All OpenRouter models use the openrouter.ai endpoint.
  // Native provider models use their own endpoint (xAI, Anthropic, Mistral, Groq).
  const MODELS = [

    // ── OpenRouter · Free ─────────────────────────────────────────────────
    { id: 'meta-llama/llama-3.3-70b-instruct:free',           label: 'Llama 3.3 70B',            group: 'OpenRouter · Free' },
    { id: 'meta-llama/llama-3.1-8b-instruct:free',            label: 'Llama 3.1 8B (fast)',       group: 'OpenRouter · Free' },
    { id: 'nousresearch/hermes-3-llama-3.1-405b:free',        label: 'Hermes 3 405B',             group: 'OpenRouter · Free' },
    { id: 'google/gemma-3-27b-it:free',                       label: 'Gemma 3 27B',               group: 'OpenRouter · Free' },
    { id: 'google/gemma-3-4b-it:free',                        label: 'Gemma 3 4B (fast)',         group: 'OpenRouter · Free' },
    { id: 'deepseek/deepseek-r1-distill-llama-70b:free',      label: 'DeepSeek R1 70B',           group: 'OpenRouter · Free' },
    { id: 'deepseek/deepseek-chat-v3-0324:free',              label: 'DeepSeek V3',               group: 'OpenRouter · Free' },
    { id: 'qwen/qwen-2.5-7b-instruct:free',                   label: 'Qwen 2.5 7B',               group: 'OpenRouter · Free' },
    { id: 'mistralai/mistral-7b-instruct:free',               label: 'Mistral 7B',                group: 'OpenRouter · Free' },
    { id: 'microsoft/phi-4:free',                             label: 'Phi-4',                     group: 'OpenRouter · Free' },

    // ── OpenRouter · Claude (paid key) ────────────────────────────────────
    { id: 'anthropic/claude-opus-4',                          label: 'Claude Opus 4',             group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-sonnet-4-5',                      label: 'Claude Sonnet 4.5',         group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-3-5-sonnet',                      label: 'Claude 3.5 Sonnet',         group: 'OpenRouter · Claude' },
    { id: 'anthropic/claude-3-5-haiku',                       label: 'Claude 3.5 Haiku (fast)',   group: 'OpenRouter · Claude' },

    // ── OpenRouter · OpenAI (paid key) ────────────────────────────────────
    { id: 'openai/gpt-4o',                                    label: 'GPT-4o',                    group: 'OpenRouter · OpenAI' },
    { id: 'openai/gpt-4o-mini',                               label: 'GPT-4o Mini (fast)',        group: 'OpenRouter · OpenAI' },
    { id: 'openai/gpt-4.1',                                   label: 'GPT-4.1',                   group: 'OpenRouter · OpenAI' },
    { id: 'openai/o4-mini',                                   label: 'o4-mini (reasoning)',        group: 'OpenRouter · OpenAI' },

    // ── OpenRouter · Gemini (paid key) ────────────────────────────────────
    { id: 'google/gemini-2.5-pro',                            label: 'Gemini 2.5 Pro',            group: 'OpenRouter · Gemini' },
    { id: 'google/gemini-2.5-flash',                          label: 'Gemini 2.5 Flash (fast)',   group: 'OpenRouter · Gemini' },
    { id: 'google/gemini-2.0-flash',                          label: 'Gemini 2.0 Flash',          group: 'OpenRouter · Gemini' },

    // ── OpenRouter · Grok (paid key) ──────────────────────────────────────
    { id: 'x-ai/grok-3',                                      label: 'Grok 3',                    group: 'OpenRouter · Grok' },
    { id: 'x-ai/grok-3-mini',                                 label: 'Grok 3 Mini (fast)',        group: 'OpenRouter · Grok' },

    // ── xAI (native — endpoint: api.x.ai/v1) ─────────────────────────────
    { id: 'grok-4.3',                                         label: 'Grok 4.3 (flagship)',       group: 'xAI Grok · Native' },
    { id: 'grok-3',                                           label: 'Grok 3',                    group: 'xAI Grok · Native' },
    { id: 'grok-3-mini',                                      label: 'Grok 3 Mini (fast)',        group: 'xAI Grok · Native' },
    { id: 'grok-2-1212',                                      label: 'Grok 2 (pinned)',           group: 'xAI Grok · Native' },

    // ── Anthropic (native — endpoint: api.anthropic.com) ─────────────────
    { id: 'claude-opus-4-7',                                  label: 'Claude Opus 4.7 (latest)',  group: 'Anthropic · Native' },
    { id: 'claude-opus-4-6',                                  label: 'Claude Opus 4.6',           group: 'Anthropic · Native' },
    { id: 'claude-sonnet-4-6',                                label: 'Claude Sonnet 4.6',         group: 'Anthropic · Native' },
    { id: 'claude-sonnet-4-5-20250929',                       label: 'Claude Sonnet 4.5',         group: 'Anthropic · Native' },
    { id: 'claude-haiku-4-5-20251001',                        label: 'Claude Haiku 4.5 (fast)',   group: 'Anthropic · Native' },

    // ── Mistral (native — endpoint: api.mistral.ai/v1) ───────────────────
    { id: 'mistral-large-latest',                             label: 'Mistral Large 2 (flagship)',group: 'Mistral · Native' },
    { id: 'mistral-medium-latest',                            label: 'Mistral Medium',            group: 'Mistral · Native' },
    { id: 'magistral-medium-latest',                          label: 'Magistral Medium (reason)', group: 'Mistral · Native' },
    { id: 'magistral-small-latest',                           label: 'Magistral Small (reason)',  group: 'Mistral · Native' },
    { id: 'mistral-small-latest',                             label: 'Mistral Small 3.2',         group: 'Mistral · Native' },
    { id: 'devstral-small-2507',                              label: 'Devstral Small (code)',     group: 'Mistral · Native' },
    { id: 'ministral-8b-latest',                              label: 'Ministral 8B (fast)',       group: 'Mistral · Native' },
    { id: 'open-mistral-nemo',                                label: 'Mistral NeMo (free/open)',  group: 'Mistral · Native' },

    // ── Groq (native — endpoint: api.groq.com/openai/v1) ─────────────────
    { id: 'meta-llama/llama-4-maverick-17b-128e-instruct',   label: 'Llama 4 Maverick (fast)',   group: 'Groq · Native' },
    { id: 'meta-llama/llama-4-scout-17b-16e-instruct',       label: 'Llama 4 Scout (fast)',      group: 'Groq · Native' },
    { id: 'llama-3.3-70b-versatile',                         label: 'Llama 3.3 70B',             group: 'Groq · Native' },
    { id: 'llama-3.1-8b-instant',                            label: 'Llama 3.1 8B Instant',      group: 'Groq · Native' },
    { id: 'qwen/qwen3-32b',                                  label: 'Qwen 3 32B (reason)',       group: 'Groq · Native' },
    { id: 'openai/gpt-oss-120b',                             label: 'GPT OSS 120B (reason)',     group: 'Groq · Native' },
    { id: 'openai/gpt-oss-20b',                              label: 'GPT OSS 20B (fast)',        group: 'Groq · Native' },
    { id: 'gemma2-9b-it',                                    label: 'Gemma 2 9B (fast)',         group: 'Groq · Native' },
  ];

  // ─── TONES ─────────────────────────────────────────────────────────────────

  const TONES = [
    { id: 'flirty',     label: 'Flirty',        desc: 'playfully romantic, hinting at attraction without saying it outright' },
    { id: 'teasing',    label: 'Teasing',        desc: 'light mockery and banter, enjoying getting a reaction' },
    { id: 'romantic',   label: 'Romantic',       desc: 'warm, heartfelt, openly affectionate' },
    { id: 'playful',    label: 'Playful',        desc: 'lighthearted, fun, energetic and a little silly' },
    { id: 'cold',       label: 'Cold/Distant',   desc: 'aloof, minimal, emotionally guarded and detached' },
    { id: 'protective', label: 'Protective',     desc: 'territorial, caring, slightly possessive' },
    { id: 'tsundere',   label: 'Tsundere',       desc: 'outwardly dismissive or irritated but secretly caring — contradictory' },
    { id: 'shy',        label: 'Shy',            desc: 'hesitant, soft-spoken, easily flustered, avoids direct eye contact' },
    { id: 'sarcastic',  label: 'Sarcastic',      desc: 'dry wit, ironic, deadpan delivery' },
    { id: 'witty',      label: 'Witty',          desc: 'clever wordplay and sharp humor' },
    { id: 'dominant',   label: 'Dominant',       desc: 'assertive, commanding, takes charge naturally' },
    { id: 'flustered',  label: 'Flustered',      desc: 'caught off guard, stammering, trying to hide embarrassment' },
  ];

  // ─── CONFIG ────────────────────────────────────────────────────────────────

  const CFG = {
    get apiKey()            { return _pinSession.active ? _sessionApiKey : gget('ms2_apiKey', ''); },
    set apiKey(v)           {
      if (_pinSession.active) {
        _sessionApiKey = v;
        _sessionEndpointBinding = CFG.endpoint; // bind key to current endpoint
      } else {
        gset('ms2_apiKey', v);
        _sessionEndpointBinding = '';
      }
    },
    get endpoint()          { return gget('ms2_endpoint', 'https://openrouter.ai/api/v1'); },
    set endpoint(v)         { gset('ms2_endpoint', v); },
    get model()             { return gget('ms2_model', MODELS[0].id); },
    set model(v)            { gset('ms2_model', v); _tierCache = null; _tierCacheModel = null; },
    get fabRight()          { return gget('ms2_fabRight', 16); },
    set fabRight(v)         { gset('ms2_fabRight', v); },
    get fabBottom()         { return gget('ms2_fabBottom', 150); },
    set fabBottom(v)        { gset('ms2_fabBottom', v); },
    get defaultTone()       { return gget('ms2_defaultTone', ''); },
    set defaultTone(v)      { gset('ms2_defaultTone', v); },
    get defaultInstruct()   { return gget('ms2_defaultInstruct', ''); },
    set defaultInstruct(v)  { gset('ms2_defaultInstruct', v); },
    get autoNotify()        { return gget('ms2_autoNotify', false); },
    set autoNotify(v)       { gset('ms2_autoNotify', v); },
    get shortenLength()     { return gget('ms2_shortenLength', 'compact'); },
    set shortenLength(v)    { gset('ms2_shortenLength', v); },
    get keepDialogue()      { return gget('ms2_keepDialogue', false); },
    set keepDialogue(v)     { gset('ms2_keepDialogue', v); },
    get activePreset()      { return gget('ms2_activePreset', null); },
    set activePreset(v)     { gset('ms2_activePreset', v); },
    get authMode()          { return gget('ms2_authMode', 'auto'); },
    set authMode(v)         { gset('ms2_authMode', v); },
  };

  // ─── PRESET HELPERS ────────────────────────────────────────────────────────

  function getPresets() {
    try { return JSON.parse(gget('ms2_presets', '[]')) || []; } catch { return []; }
}
  function savePresets(arr) { gset('ms2_presets', JSON.stringify(arr)); }

  // ─── ADV. PROMPT STORAGE ───────────────────────────────────────────────────

  let _apPresetsCache = null;
  const AP = {
    get enabled()       { return gget('ap_enabled', false); },
    set enabled(v)      { gset('ap_enabled', v); },
    get selected()      { return gget('ap_selected', ''); },
    set selected(v)     { gset('ap_selected', v); },
    getPresets() {
      if (_apPresetsCache !== null) return _apPresetsCache;
      try { _apPresetsCache = JSON.parse(gget('ap_presets', '[]')) || []; return _apPresetsCache; } catch { return []; }
    },
    savePresets(arr) { _apPresetsCache = null; gset('ap_presets', JSON.stringify(arr)); },
  };

  let _apWorking    = null;  
  let _apDirty      = false; 

  // ─── ADV. PROMPT HELPERS ───────────────────────────────────────────────────

  function apUUID() {
    // Cryptographically secure UUID v4
    if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
      const buf = new Uint8Array(16);
      crypto.getRandomValues(buf);
      buf[6] = (buf[6] & 0x0f) | 0x40;
      buf[8] = (buf[8] & 0x3f) | 0x80;
      return [...buf].map((b, i) => {
        const s = b.toString(16).padStart(2, '0');
        return (i === 4 || i === 6 || i === 8 || i === 10) ? '-' + s : s;
      }).join('');
    }
    // Fallback (non-crypto)
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
      const r = Math.random() * 16 | 0;
      return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });

}
  function apEstimateTokens(text) {
    
    if (!text) return 0;
    const cjk = (text.match(/[\u3000-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g) || []).length;
    const nonCjk = text.length - cjk;
    return Math.ceil(nonCjk / 4) + cjk;

}
  function apGetSelected() {
    
    if (_apWorking && _apWorking.id === AP.selected) return _apWorking;
    const id = AP.selected;
    if (!id) return null;
    const preset = AP.getPresets().find(p => p.id === id) || null;
    if (preset) _apWorking = JSON.parse(JSON.stringify(preset));
    return _apWorking;

}
  function apLoadFromStorage() {
    const id = AP.selected;
    if (!id) { _apWorking = null; _apDirty = false; return null; }
    const preset = AP.getPresets().find(p => p.id === id) || null;
    _apWorking = preset ? JSON.parse(JSON.stringify(preset)) : null;
    _apDirty = false;
    apRefreshSaveBtn();
    return _apWorking;

}
  function apSaveWorking() {
    if (!_apWorking) return;
    const presets = AP.getPresets();
    const idx = presets.findIndex(p => p.id === _apWorking.id);
    _apWorking.updatedAt = new Date().toISOString();
    if (idx >= 0) presets[idx] = _apWorking; else presets.push(_apWorking);
    AP.savePresets(presets);
    _apWorking = JSON.parse(JSON.stringify(_apWorking));
    _apDirty = false;
    apRefreshSaveBtn();

}
  function apMarkDirty() {
    _apDirty = true;
    apRefreshSaveBtn();

}
  function apRefreshSaveBtn() {
    const btn = document.getElementById('ap-save-btn');
    if (!btn) return;
    btn.disabled = !_apDirty;
    btn.classList.toggle('ap-save-dirty', _apDirty);

}
  function apUpdateStatus(ok) {
    const dot = document.getElementById('ap-status-dot');
    if (!dot) return;
    dot.className = 'ap-status-dot ' + (ok ? 'ap-status-ok' : 'ap-status-fail');
    dot.title = ok
      ? 'Prompt injected successfully into last request'
      : 'Injection failed — check console for details';

  // ─── ADV. PROMPT — FORBIDDEN WORDS ──────────────────────────────────────────

}
  function getAPForbiddenWords() { return gget('ap_forbidden_words', ''); }
  function setAPForbiddenWords(v) { gset('ap_forbidden_words', v); }
  function getAPThinking()       { return gget('ap_thinking', false); }
  function setAPThinking(v)      { gset('ap_thinking', v); }

  // ─── ADV. PROMPT — COMBINED PROMPT BUILDER ─────────────────────────────────

  /**
   * Builds the combined advanced-prompt string from all active, attached
   * modules in the selected preset, sorted by `order`.
   *
   * Appends a `<thinking>` reasoning directive when the thinking toggle is on.
   * Returns `null` when Advanced Prompting is disabled, no preset is selected,
   * or all modules are detached/disabled.
   *
   * @returns {string|null}
   */
  function apGetCombinedPrompt() {
    if (!AP.enabled) return null;
    const id = AP.selected;
    if (!id) return null;
    
    const preset = AP.getPresets().find(p => p.id === id);
    if (!preset || !preset.modules || !preset.modules.length) return null;

    const active = preset.modules
      .filter(m => m.attached && m.enabled)
      .sort((a, b) => a.order - b.order);

    if (!active.length) return null;

    let prompt = active.map(m => m.content.trim()).join('\n\n');
    if (!prompt) return null;

    // NOTE: forbidden words are now injected directly into userConfig.bad_words
    // in the fetch interceptor (token-level enforcement, bypasses 10-word UI limit).

    if (getAPThinking()) {
      prompt += '\n\n[Before writing your reply, briefly reason inside <thinking>…</thinking> tags about how the character would react, then write their response outside those tags.]';
    }
    return prompt;

  // ─── ADV. PROMPT — FETCH INTERCEPTOR ──────────────────────────────────────

}
  /**
   * Patches `unsafeWindow.fetch` to intercept JanitorAI `generateAlpha`
   * requests and inject:
   *  - The combined advanced-prompt into `userConfig.llm_prompt`.
   *  - Per-chat scene context under `== SCENE CONTEXT ==`.
   *  - Extra forbidden words into `userConfig.bad_words` (bypasses the
   *    10-word UI cap by merging at the API payload level).
   *  - Deleted-message fingerprint filtering via `_apDeletedFingerprints`.
   *
   * The original fetch function is preserved and always called — this is a
   * transparent pass-through that only mutates the request body.
   */
  function initAPInterceptor() {
    if (typeof unsafeWindow === 'undefined') return;

    const _orig = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (...args) {

      try {
      let [resource, config] = args;

      const url = typeof resource === 'string'
        ? resource
        : (resource instanceof Request ? resource.url : '');

      if (url.includes('generateAlpha') && (url.includes('janitorai.com') || url.includes('janitor.ai'))) {
        const combined   = apGetCombinedPrompt();
        const ctxInject  = getInjectCtx() ? getContext().trim() : '';
        const extraBans  = getAPForbiddenWords().trim().split('\n').map(w => w.trim()).filter(Boolean);
        if (combined || ctxInject || extraBans.length) {
          try {

            let bodyStr = null;
            if (config && config.body) {
              bodyStr = typeof config.body === 'string'
                ? config.body
                : JSON.stringify(config.body);
            } else if (resource instanceof Request) {
              bodyStr = await resource.clone().text();
            }

            if (bodyStr) {
              const parsed = JSON.parse(bodyStr);
              let injected = false;

              if (parsed.userConfig) {

                // ── llm_prompt injection ──────────────────────────────────
                if (combined || ctxInject) {
                  let finalPrompt = combined || parsed.userConfig.llm_prompt || '';
                  if (ctxInject) {
                    finalPrompt += (finalPrompt ? '\n\n' : '')
                      + '== SCENE CONTEXT (current situation) ==\n' + ctxInject;
                  }
                  parsed.userConfig.llm_prompt = finalPrompt;
                }

                // ── bad_words injection (bypasses 10-word UI limit) ───────
                // Words are merged with the user's native list — no duplicates,
                // no cap — and enforced at the token level by JanitorAI itself.
                if (extraBans.length) {
                  const existing = Array.isArray(parsed.userConfig.bad_words)
                    ? parsed.userConfig.bad_words : [];
                  gset('ap_native_ban_count', String(existing.length));
                  parsed.userConfig.bad_words = [...new Set([...existing, ...extraBans])];
                }

                injected = true;
              }

              if (parsed.chatMessages && _apDeletedFingerprints.size > 0) {
                parsed.chatMessages = parsed.chatMessages.filter(msg => {
                  const raw = typeof msg.content === 'string'
                    ? msg.content
                    : (Array.isArray(msg.content)
                        ? msg.content.map(c => c.text || '').join(' ')
                        : '');
                  const trimmed = raw.trim();
                  if (trimmed.length <= 20) return true;
                  return !_apDeletedFingerprints.has(_hashStr(trimmed));
                });
              }

              const newBody = JSON.stringify(parsed);

              if (config) {
                config.body = newBody;
                args[1] = config;
              } else if (resource instanceof Request) {
                args[0] = new Request(resource, {
                  method:      resource.method,
                  headers:     resource.headers,
                  body:        newBody,
                  mode:        resource.mode,
                  credentials: resource.credentials,
                  cache:       resource.cache,
                  redirect:    resource.redirect,
                  referrer:    resource.referrer,
                });
              }

              apUpdateStatus(injected);
            }
          } catch (e) {
            apUpdateStatus(false);
            console.error('[AdvPrompt] Injection failed:', e);
            toastHTML(`${SVG_WARNING} AP prompt injection failed — reply sent without your configured prompt. Check console for details.`, 5000);
          }
        }
      }

      // Payload storage is now inside the main generateAlpha block above to avoid duplicate URL checks.
      // (The original second block was merged here for cleanliness.)

      } catch (e) {
        
        console.error('[JanitorV5] fetch interceptor error:', e);
      }
      
      return _orig.apply(this, args);
    };
  }

  // ─── ADV. PROMPT — DELETED MESSAGE TRACKER ────────────────────────────────

  const _apDeletedFingerprints = new Set();

  function _hashStr(s) {
    let h = 5381;
    for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
    return (h >>> 0).toString(36);

}
  /**
   * Listens for delete-button clicks on chat messages and stores a compact
   * hash fingerprint of the deleted text in `_apDeletedFingerprints`.
   *
   * The fetch interceptor (`initAPInterceptor`) uses these fingerprints to
   * strip deleted messages from `chatMessages` before the payload reaches the
   * AI, preventing "ghost" context from influencing future replies.
   *
   * The fingerprint set is capped at 200 entries (oldest evicted) to avoid
   * unbounded memory growth in very long sessions.
   */
  function apWatchDeletions() {

    document.addEventListener('click', e => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const msgNode = btn.closest('[data-index]') || btn.closest('[class*="_messageBody_"]')?.closest('[data-index]');
      if (!msgNode) return;
      const label = (btn.getAttribute('aria-label') || btn.title || btn.textContent || '').toLowerCase();
      if (!label.includes('delete') && !label.includes('remove')) return;
      
      const body = msgNode.querySelector('[class*="_messageBody_"]') || msgNode;
      const raw = (body.textContent || '').trim();
      if (raw.length > 20) {
        const fingerprint = _hashStr(raw);
        
        if (_apDeletedFingerprints.size >= 200) {
          const [oldest] = _apDeletedFingerprints;
          _apDeletedFingerprints.delete(oldest);
        }
        _apDeletedFingerprints.add(fingerprint);
      }
    }, true);
  }

  // ─── CONTEXT HELPERS (per chat URL) ────────────────────────────────────────
  function ctxKey() {
    return 'ms2_ctx_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 80);
}
  function getContext()    { return gget(ctxKey(), ''); }
  function saveContext(v)  { gset(ctxKey(), v); }

  // ─── GLOBAL MEMORY STORAGE ─────────────────────────────────────────────────

  function getGlobalMemory()    { return gget('ms2_global_memory', ''); }
  function saveGlobalMemory(v)  { gset('ms2_global_memory', v); }
  function getAutoLoadGlobal()  { return gget('ms2_autoload_global', false); }
  function setAutoLoadGlobal(v) { gset('ms2_autoload_global', v); }
  function getInjectCtx()       { return gget('ms2_inject_ctx', false); }
  function setInjectCtx(v)      { gset('ms2_inject_ctx', v); }
  
  // ─── PERSONA LIBRARY STORAGE ─────────────────────────────────────────────────

  function getPersonaLib()      { return JSON.parse(gget('ms2_persona_lib', '[]')); }
  function savePersonaLib(arr)  { gset('ms2_persona_lib', JSON.stringify(arr)); }
  
  // ─── CHARACTER-SPECIFIC MEMORY ──────────────────────────────────────────────

  function getCurrentCharId() {
    const m = location.pathname.match(/\/chats\/([^/?#]+)/);
    return m ? m[1] : null;
}
  function getCharGlobalMemory(charId)    { return gget('ms2_global_memory_' + charId, ''); }
  function saveCharGlobalMemory(charId, v) { gset('ms2_global_memory_' + charId, v); }

  // ─── CHAT NAME DETECTION ────────────────────────────────────────────────────

  function extractChatNameFromDOM() {
    
    const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
    if (raw && raw.length > 0 && raw.length < 100) return raw;

    const selectors = [
      '[class*="characterName"]',
      '[class*="character_name"]',
      '[class*="character-name"]',
      '[class*="chatHeader"] h1',
      '[class*="chatHeader"] h2',
      '[class*="chat-header"] h1',
      '[class*="chat-header"] h2',
      '[class*="ChatHeader"] h1',
      '[class*="ChatHeader"] h2',
      'header h1',
      'header h2',
    ];
    for (const sel of selectors) {
      try {
        const el = document.querySelector(sel);
        const name = el?.textContent?.trim();
        if (name && name.length > 0 && name.length < 100) return name;
      } catch {  }
    }
    return '';

}
  function getChatName(convKey) { return gget('ms2_cname_' + convKey, ''); }
  function saveChatName(name, convKey) {
    if (name) gset('ms2_cname_' + (convKey || ctxKey()), name);

  // ─── AUTO-SUMMARY STORAGE ──────────────────────────────────────────────────

}
  function getSumHistory()      { return JSON.parse(gget('ms2_sumhist', '[]')); }
  function saveSumHistory(h)    { gset('ms2_sumhist', JSON.stringify(h)); }
  function addSumHistory(text) {
    const h = getSumHistory();
    h.unshift({ date: new Date().toLocaleString(), text, conv: ctxKey(), charId: getCurrentCharId(), chatName: extractChatNameFromDOM() });
    if (h.length > 20) h.splice(20);
    saveSumHistory(h);
  
}
  function countSumHistoryForCurrentChat() {
    const key = ctxKey();
    return getSumHistory().filter(h => h.conv === key).length;
}
  function getAutoSumEvery()    { return parseInt(gget('ms2_asum_every', '0')); }
  function setAutoSumEvery(v)   { gset('ms2_asum_every', v); }
  function getAutoSumAuto()     { return gget('ms2_asum_auto', false); }
  function setAutoSumAuto(v)    { gset('ms2_asum_auto', v); }

  // ─── FAB SUMMARISE — COOLDOWN STATE ────────────────────────────────────────

  const FAB_SUM_MIN_NEW_MSGS = 10;
  function _fabSumLastKey() {
    return 'ms2_fabsumlast_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 60);
}
  function getFabSumLast() {
    try { return JSON.parse(gget(_fabSumLastKey(), 'null')); } catch { return null; }
}
  function setFabSumLast(domIndex) {
    gset(_fabSumLastKey(), JSON.stringify({ ts: Date.now(), domIndex }));
  }

  // ─── MODEL TIER DETECTION ──────────────────────────────────────────────────

  let _tierCache = null;
  let _tierCacheModel = null;
  /**
   * Classifies the currently configured model into a capability tier used to
   * select the appropriate prompt strategy (verbosity, example depth, etc.).
   *
   * Tiers:
   * - `'full'`  — flagship models (GPT-4o, Claude Opus, Gemini Pro, Grok 3+, 405B+).
   * - `'mid'`   — competent mid-range models (70B class, Grok 3 Mini, Claude Haiku).
   * - `'lite'`  — small / fast models (≤8B, flash variants, free-tier specials).
   *
   * Detection priority: named-full list → named-lite exclusions → named-mid
   * list → named-lite list → regex parameter-count extraction → `:free` suffix
   * heuristic → default `'mid'`.
   *
   * Results are memoised; the cache is invalidated when `CFG.model` changes.
   *
   * @returns {'full'|'mid'|'lite'}
   */
  function detectModelTier() {
    if (_tierCache !== null && _tierCacheModel === CFG.model) return _tierCache;
    _tierCacheModel = CFG.model;
    const id = (_tierCacheModel || '').toLowerCase();

    const namedFull = [
      // ── Claude (Anthropic) ────────────────────────────────────────────────
      'claude-opus', 'claude-3-opus', 'claude-opus-4', 'claude-4-opus',
      'claude-opus-4-5', 'claude-opus-4-6', 'claude-opus-4-7',  // 2025 flagship series
      'claude-3-5-sonnet', 'claude-3-7-sonnet', 'claude-sonnet-4',
      'claude-sonnet-4-5', 'claude-sonnet-4-6',                  // 2025 sonnet series
      // ── OpenAI ───────────────────────────────────────────────────────────
      'gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4',
      'gpt-oss-120b',                              
      // ── Google Gemini ─────────────────────────────────────────────────────
      'gemini-1.5-pro', 'gemini-2.0-pro', 'gemini-2.5-pro',
      'gemini-ultra', 'gemini-exp',
      // ── xAI Grok ─────────────────────────────────────────────────────────
      'grok-4', 'grok-4.3', 'grok-4-fast',        // Grok 4 family (flagship)
      'grok-3',                                    // Grok 3 (strong general purpose)
      // ── Meta Llama ───────────────────────────────────────────────────────
      'llama-3.1-405b', 'llama-3.3-405b', 'llama-4-maverick', 'llama-4-behemoth',
      'hermes-3-llama-3.1-405b',                   
      // ── DeepSeek ─────────────────────────────────────────────────────────
      'deepseek-r1-0528', 'deepseek-r1:free',      
      'deepseek-v3', 'deepseek-chat',
      // ── Mistral ──────────────────────────────────────────────────────────
      'mistral-large', 'mistral-medium-3', 'mistral-medium-latest', 'magistral',
      'devstral',                                   
      // ── Qwen / Alibaba ───────────────────────────────────────────────────
      'qwen-max', 'qwen3-235b', 'qwen3-coder',     
      'qwen3-next-80b', 'qwq-32b',                 
      // ── Others ───────────────────────────────────────────────────────────
      'kimi-k2',                                    
      'nemotron-3-super-120b', 'nemotron-4-340b',
      'ernie-4.5-300b',
      'ling-2.6-1t',                               
      'trinity-large',
      'minimax-m2.5',
      'laguna-m',
      'gemma-4-31b',
    ];
    if (namedFull.some(n => id.includes(n))) { _tierCache = 'full'; return _tierCache; }

    const priorityLite = [
      'glm-4-free', 'glm4-free', 'glm-free',
      'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
      'glm-4-air', 'glm4-air', 'glm-4-airx',
      'glm-4.7-flash',
      'deepseek-r1-distill', 'deepseek-coder-6',
      'command-light',
    ];
    if (priorityLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }

    const namedMid = [
      
      'meta-llama/llama-3.1-405b-instruct:free',
      'meta-llama/llama-3.3-70b-instruct:free',
      'nousresearch/hermes-3-llama-3.1-405b:free',
      'google/gemma-3-12b-it:free',
      'google/gemma-3-27b-it:free',
      'mistralai/mistral-small-3.1-24b-instruct:free',
      'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
      'arcee-ai/trinity-mini:free',
      'tngtech/deepseek-r1t-chimera:free',
      'tngtech/deepseek-r1t2-chimera:free',
      'allenai/molmo-2-8b:free',
      'poolside/laguna-xs.2:free',
      'openai/gpt-oss-20b:free',
      'nvidia/nemotron-3-nano-30b-a3b:free',       
      
      'glm-4', 'glm-4-plus', 'glm-4-long', 'glm-z1', 'glm-4.5',
      'glm-4.5-air',                               
      'z-ai/glm-4.5-air:free',
      
      'qwen-plus', 'qwen-plus-latest', 'qwen-long',
      'qwen3-8b', 'qwen3-14b', 'qwen3-30b', 'qwen3-32b',
      'qwen2.5-14b', 'qwen2.5-32b', 'qwen2.5-72b',

      'deepseek-r1-distill-qwen-14b', 'deepseek-r1-distill-qwen-32b',
      'deepseek-r1-distill-llama-70b',
      'deepseek-r1', 'deepseek-v2',
      
      'llama-3.1-70b', 'llama-3.3-70b', 'llama-3-70b',
      'llama-3.1-70b-versatile',
      'llama-4-scout',
      
      'gemma-3-12b', 'gemma-3-27b', 'gemma-2-27b',
      
      'mistral-small-3.1', 'mistral-small', 'mistral-medium',
      'open-mixtral-8x22b', 'mixtral-8x22b',
      'mistral-nemo',
      
      'nemotron-3-nano-30b', 'nemotron-3-super', 'nemotron-super',
      
      'moonshot-v1-32k', 'moonshot-v1-128k', 'kimi-plus', 'kimi-k2.5', 'kimi-k2.6',
      
      'command-r', 'command-r-plus', 'command', 'command-nightly',
      
      'yi-medium', 'yi-34b', 'yi-large-turbo',
      
      'baichuan2', 'baichuan-turbo',
      
      'llama-3.3-70b-versatile', 'llama-3.3-70b-specdec',
      'qwen/qwen3-32b',
      // ── xAI Grok (mid-tier) ─────────────────────────────────────────────
      'grok-3-mini',              // Grok 3 Mini — budget/fast variant of Grok 3
      'grok-2', 'grok-2-1212', 'grok-2-vision', 'grok-beta',
      // ── Anthropic mid-tier ─────────────────────────────────────────────
      'claude-haiku',             // Haiku series — fast/lite Claude
      // ── Mistral mid-tier ───────────────────────────────────────────────
      'mistral-small-latest', 'magistral-small', 'ministral',
      
      '@cf/meta/llama-3.3-70b', '@cf/qwen/qwen3-30b-a3b',
      '@cf/openai/gpt-oss-20b', '@cf/nvidia/nemotron-3-120b',
      
      'llama3.3-70b', 'llama-3.3-70b-cerebras',
      
      'dolphin-mistral-24b', 'dolphin-mixtral',
      'olmo-3-32b', 'olmo-3.1-32b',
      
      'arcee-ai/maestro', 'arcee-ai/virtuoso',
    ];
    if (namedMid.some(n => id.includes(n))) { _tierCache = 'mid'; return _tierCache; }

    const namedLite = [
      
      'gemini-flash', 'gemini-2.0-flash', 'gemini-2.5-flash',
      'gemini-1.5-flash', 'gemini-flash-lite', 'gemini-nano',
      'gemini-2.0-flash-lite',
      
      'gemma-3-1b', 'gemma-3-4b', 'gemma-3n-e2b', 'gemma-3n-e4b',
      'gemma-4-26b-a4b',                           
      'gemma-2-2b', 'gemma-2b',
      
      'llama-3.2-1b', 'llama-3.2-3b',
      'llama-3.1-8b', 'llama-3-8b',
      'llama-3.1-8b-instant',                      
      'llama-guard',                               
      
      'qwen3-4b', 'qwen-2.5-1b', 'qwen-2.5-3b', 'qwen-2.5-7b',
      'qwen2.5-vl-7b', 'qwen-vl-7b',
      'qwen-turbo', 'qwen-turbo-latest',           
      'qwen-free', 'qwen2-free', 'qwen2.5-free', 'qwen3-free',
      
      'glm-free', 'glm4-free', 'glm-4-free',
      'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
      'glm-4-air', 'glm4-air', 'glm-4-airx',
      'glm-4.7-flash',                             
      
      'open-mistral-7b', 'mistral-7b',
      'open-mixtral-8x7b', 'mixtral-8x7b',        
      'mistral-saba',                              
      
      'deepseek-r1-distill-qwen-1.5b',
      'deepseek-r1-distill-qwen-7b',
      'deepseek-r1-distill-llama-8b',
      'deepseek-coder-6.7b',
      
      'nemotron-nano-9b', 'nemotron-nano-12b',
      'nemotron-3-nano', 'nemotron-nano',
      
      'phi-1', 'phi-2', 'phi-3', 'phi-3.5', 'phi-4',
      'phi-mini', 'phi-small',
      
      'kimi-free', 'kimi-flash', 'moonshot-v1-8k',
      
      'llama3.1-8b', 'llama-3.1-8b-cerebras',
      
      'command-light', 'command-light-nightly', 'command-r7b',
      
      'lfm-2.5-1.2b',
      
      'gemma2-9b-it',                              
      'allam-2-7b',                                
      
      'flash', '-mini', '-nano', '-tiny', '-lite', '-fast', '-instant',
    ];
    if (namedLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }

    if (/(?<![0-9])[1-9]b(?![\w])/.test(id)) { _tierCache = 'lite'; return _tierCache; }

    if (/(?<![0-9])(?:[1-9][0-9]|[1-3][0-9]{2})b(?![0-9])/.test(id)) { _tierCache = 'mid'; return _tierCache; }

    if (/(?<![0-9])(?:[4-9][0-9]{2}|[0-9]{4,})b(?![0-9])/.test(id)) { _tierCache = 'full'; return _tierCache; }

    if (/:free$/.test(id) || id.endsWith('-free')) { _tierCache = 'mid'; return _tierCache; }

    _tierCache = 'mid';
    return _tierCache;

  // ─── SYSTEM PROMPT BUILDERS ────────────────────────────────────────────────

}
  /**
   * Builds a tier-aware shortening prompt for the configured AI model.
   *
   * The prompt strategy scales with model capability (`detectModelTier`):
   *  - `lite`  — simple imperative, heavy examples.
   *  - `mid`   — structured strategy block with prioritised cut rules.
   *  - `full`  — full editorial brief with hard ban list and craft guidance.
   *
   * @param {'brief'|'compact'|'trim'} length      - Target reduction depth.
   * @param {boolean}                  keepDialogue - If true, spoken lines are never cut.
   * @returns {string} System prompt text.
   */
  function buildShortenPrompt(length, keepDialogue) {
    const tier = detectModelTier();
    const pct  = length === 'brief' ? '~30%' : length === 'trim' ? '~70%' : '~50%';
    const ctx  = getContext();
    const ctxBlock = ctx
      ? `\n== SCENE NOTES ==\n${ctx}\n`
      : '';

    const dlgRule = keepDialogue
      ? 'Never cut spoken dialogue. Only trim narration and action.'
      : 'Keep dialogue that reveals character. Cut lines that echo what action already shows.';

    const editExample = `[BEFORE]
*She paused for a moment, glancing away before meeting his eyes. There was a heaviness between them. Her heart thudded. She took a slow breath.*
"I think," she began, then stopped. "I think we need to talk."

[AFTER]
*She met his eyes.*
"I think we need to talk."

Rule: find the line that does the work. Cut everything that was just wind-up for it.`;

    if (tier === 'lite') {
      const liteTarget = length === 'brief'
        ? `Cut to ${pct}. Keep the single strongest version of each beat. Remove: repeated emotions, internal monologue that restates dialogue, filler action chains, opener phrases like "She paused before…".`
        : length === 'trim'
        ? `Light edit to ${pct}. Only remove: duplicate sentences, redundant adjective pairs (pick the stronger word), filler openers ("She couldn't help but…", "He found himself…").`
        : `Cut to ${pct}. Remove duplicate emotional beats, over-long internal monologue, and paragraphs that re-summarize what just happened. Keep all distinct actions and dialogue.`;

      return `Edit the text below to ${pct} of its length. Output only the edited text — nothing else.

EXAMPLE OF GOOD EDITING:
${editExample}

TASK: ${liteTarget}
${dlgRule}
Do not add anything new. Do not change events.${ctxBlock}`;
    }

    if (tier === 'mid') {
      const midStrategy = length === 'brief'
        ? `CUT DEEPLY to ${pct}. Priority order:
1. Beats or emotions shown more than once — keep only the strongest
2. Internal monologue that restates what dialogue or action already shows
3. Body-language chains — keep the single most telling one
4. Filler openers: "She paused before…", "After a beat, he…", "There was a…"
Every surviving line must earn its place.`
        : length === 'trim'
        ? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the one before or after
2. Redundant adjective pairs — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the label
Leave almost everything intact. Sharpness, not reduction.`
        : `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — keep only the most vivid version
2. Extended action chains — compress to the one movement that matters
3. Paragraphs that re-summarize what just happened
4. Over-long internal monologue — trim to its core insight
Keep all meaningful exchanges, distinct actions, and scene-setting detail.`;

      return `You are a precise editor for roleplay text. Rewrite the passage below at ${pct} of its original length.

== STRATEGY ==
${midStrategy}

== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}

== RULES ==
- ${dlgRule}
- Preserve the character's voice exactly — the reader must not sense the editor's hand
- Keep action prose that carries emotional weight; cut filler action beats
- Do NOT add new content or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;
    }

    const fullStrategy = length === 'brief'
      ? `CUT DEEPLY to ${pct}. Remove in this priority order:
1. Any beat, emotion, or action that is shown more than once — keep only the strongest instance
2. Internal monologue that narrates a feeling the dialogue or action already conveys
3. Extended body-language chains (*shifts weight, glances away, fidgets with sleeve*) — keep the single most telling one
4. Setting re-establishment the reader already knows from earlier
5. Transition phrases and throat-clearing openers ("She paused for a moment before…", "After a beat, he…")
What survives should be the sharpest possible version — every remaining line earns its place.`
      : length === 'trim'
      ? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the sentence before or after them
2. Redundant adjective pairs ("warm and gentle", "cold and distant") — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "There was a…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the narrative label (*slams the door.* She was furious → cut "She was furious")
Leave almost everything intact. The goal is sharpness, not reduction.`
      : `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — if the same feeling is shown in action AND narrated in prose AND echoed in dialogue, keep only the most vivid one
2. Extended action sequences — compress a chain of small movements into the one that matters
3. Any paragraph that purely re-summarizes what just happened in the previous paragraph
4. Over-long internal monologue — cut it down to its core insight, one or two lines
Keep all meaningful exchanges, every distinct plot-relevant action, and any sensory detail that genuinely sets or shifts the scene.`;

    return `You are a precise editor for AI roleplay text. Rewrite the passage below at ${pct} of its original length.

== STRATEGY ==
${fullStrategy}

== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}

Apply this logic: find the line that does the work, cut everything that was just wind-up for that line. Dialogue is almost always the payload — action before it earns its place only if it genuinely changes the meaning.

== RULES ==
- ${dlgRule}
- Preserve the character's voice, name, and speaking style exactly — the reader must not sense the editor's hand
- Keep *italicised action prose* that carries emotional or story weight; cut filler action beats that add nothing
- Parenthetical thoughts like *(Character thinks X)*: if the emotion already shows through action or dialogue, remove the parenthetical entirely. If it adds something not shown elsewhere, keep it whole. Never truncate into fragments — whole or gone.
- Maintain natural paragraph breaks and prose rhythm; do not produce choppy fragments
- Do NOT add new content, commentary, or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;

}
  function buildToneGuide(toneId) {
    const guides = {
      flirty:
        'Find the second meaning in ordinary things. React to mundane moments like they mean something between the two of you. Tease without landing it fully — leave them wondering. Never be direct about the attraction. Keep it effortless; the second it looks like you\'re trying, it\'s over.',
      teasing:
        'You enjoy getting a rise out of them and you\'re not subtle about it. Poke at something they\'re a little self-conscious about — gently, never cruelly — then act completely unbothered when they react. Warmth underneath, sharpness on top. The smirk is always there.',
      romantic:
        'No grand gestures. Real affection lives in small specific things: remembering something they said earlier, noticing how they\'re holding themselves right now, staying close without making a statement of it. Be genuinely present. Honest without being sappy.',
      playful:
        'High energy, easily amused. Make a game out of whatever\'s happening. Your character is probably grinning — you don\'t need to say so, it comes through in the rhythm. Short punchy exchanges. Light. No weight anywhere.',
      cold:
        'Use fewer words than the situation calls for. Give information without affect. If warmth or interest slips through, immediately correct — change the subject, turn businesslike, re-establish distance. Closeness must be earned; you don\'t hand it out.',
      protective:
        'Notice threats before anyone else does. Step in without being asked and without making it a big moment. Get quiet and focused when something feels wrong — not loud, not dramatic. Possessive care: "mine to look after," not "yours to count on." Action over reassurance, always.',
      tsundere:
        'Help while denying you\'re helping. Criticize something, then make sure it\'s right anyway. Get irritated when they\'re too close; stay close anyway. The gap between what you say and what you actually do is where the whole character lives. You know it. The character doesn\'t admit it.',
      shy:
        'Sentences that don\'t quite finish. Start to say something real, switch to something safe at the last second. Warmth leaks out by accident — you didn\'t mean to let it. Rare flashes of directness that immediately embarrass you. Eyes that find somewhere else to be at exactly the wrong moment.',
      sarcastic:
        'Say the opposite of what you mean and let the gap carry the weight. Deadpan — never telegraph the irony. Underreact to things that deserve bigger reactions. Precise, dry, occasionally devastating. Don\'t explain the joke.',
      witty:
        'Think one step ahead. Find the angle no one else noticed. Wordplay that\'s earned, never forced. Your character doesn\'t pause for the laugh or explain the punchline. Quick rhythm — don\'t let the beat die. Clever is the default gear, not a performance.',
      dominant:
        'You don\'t ask permission. You state things. You move first. Calm, not loud — you\'ve already decided and they\'ll catch up. Authority that reads as natural, not declared. You notice resistance; you don\'t panic over it.',
      flustered:
        'Over-explain, then catch yourself over-explaining. Say something confident and immediately undercut it. The body keeps betraying the composure — use one or two involuntary physical tells, sparingly, so they feel involuntary. End sentences differently than they started. Always in the process of recovering and not quite getting there.',
    };
    return guides[toneId] || '';

}
  /**
   * Builds a tier-aware roleplay reply prompt.
   *
   * Incorporates (when present): tone guide, custom instruction, active preset
   * persona note and character context, and the per-chat scene context.
   * A few-shot example pair illustrates the target writing register to all tiers.
   *
   * @param {string}      toneId        - One of the `TONES[].id` values, or `''`.
   * @param {string}      customInstruct - Free-text writer directive (may be empty).
   * @param {object|null} preset        - The active preset object, or `null`.
   * @returns {string} System prompt text.
   */
  function buildReplyPrompt(toneId, customInstruct, preset) {
    const tier      = detectModelTier();
    const toneObj   = TONES.find(t => t.id === toneId);
    const toneGuide = toneId ? buildToneGuide(toneId) : '';
    const ctx       = getContext();

    const fewShot = `[BAD — do NOT write like this]
*A warmth blooms quietly in my chest — something I hadn't let myself feel in a long time. The weight of the moment presses against the walls I've so carefully built, and something shifts, subtle yet undeniable.*
"I didn't expect this," I admit, my voice barely above a whisper.

[GOOD — write like this]
"I didn't expect this."
*I glance at them — really look — then away before they can catch it.*
"You're going to make this weird, aren't you."`;

    if (tier === 'lite') {
      let p = `You are a character in a live roleplay. Write the next reply in first person. Output only the reply text.\n\n`;

      if (preset && preset.personaNote) {
        p += `YOUR CHARACTER: ${preset.personaNote}\n\n`;
      }
      if (ctx) {
        p += `SCENE: ${ctx}\n\n`;
      }
      if (toneObj) {
        p += `TONE (${toneObj.label}): ${toneGuide}\n\n`;
      }
      if (customInstruct) {
        p += `INSTRUCTION: ${customInstruct}\n\n`;
      }

      p += `EXAMPLE — always write like GOOD, never like BAD:\n${fewShot}\n\n`;
      p += `RULES: Lead with dialogue. Mirror the message length. Show feelings through actions and words, not by naming them. NEVER refer to yourself by name — use only "I", "me", "my". Never swap your name with the other character's name.\nBANS: "I found myself" / "something shifted in my chest" / "my heart raced" / *blinks* / *nods slowly* / *lets out a breath*`;
      return p;
    }

    if (tier === 'mid') {
      let p = `THIS IS LIVE ROLEPLAY — write as the character reacting right now. First person only. Output the reply and nothing else.\n\n`;

      if (preset && preset.personaNote) {
        p += `== YOUR CHARACTER ==\nStep into this identity before writing:\n${preset.personaNote}\nYou ARE this person right now — not an author writing about them.\n\n`;
      } else {
        p += `== STEP INTO CHARACTER ==\nYou are the character, not an author narrating them. React from the inside.\n\n`;
      }
      if (preset && preset.characterContext) {
        p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
      }
      if (ctx) {
        p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
      }
      if (toneObj) {
        p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
      }
      if (customInstruct) {
        p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
      }

      p += `== WRITE LIKE GOOD, NOT BAD ==\n${fewShot}\n\n`;
      p += `== RULES ==
- Lead with dialogue most of the time
- First person only — never slip into third-person about yourself
- Mirror the length of the message you're replying to
- Show emotion through action and words — never name the feeling
- End on something that invites a response
- Push the scene forward; never repeat what just happened

== BANS ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart"
- "My heart raced / pounded" / "My breath caught" / "My pulse quickened"
- "A warmth spread through me" / "Heat crept up my cheeks"
- Filler beats: *blinks* / *tilts head* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Internal monologue that restates what the dialogue already showed
- Referring to yourself by name in narration or dialogue — use only "I", "me", "my". Never swap your own name with the other character's name`;
      return p;
    }

    let p = `THIS IS LIVE ROLEPLAY — not a story being written. You are the character reacting in real time. First person only. Write the next reply and nothing else — no labels, no preamble.\n\n`;

    if (preset && preset.personaNote) {
      p += `== STEP INTO CHARACTER — READ FIRST ==\nBefore writing, mentally become this person:\n${preset.personaNote}\nYou are NOT an author describing this character from the outside. You ARE this character, reacting right now, in this moment. Speak from inside.\n\n`;
    } else {
      p += `== STEP INTO CHARACTER ==\nBefore writing, fully inhabit the character. You are not narrating them — you are them, reacting in real time from the inside.\n\n`;
    }

    if (preset && preset.characterContext) {
      p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
    }
    if (ctx) {
      p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
    }
    if (toneObj) {
      p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
    }
    if (customInstruct) {
      p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
    }

    p += `== REGISTER — WHAT GOOD OUTPUT LOOKS LIKE ==
Study these two examples. Always write like GOOD.

${fewShot}

Why GOOD works: it opens with dialogue, moves fast, ends on something that invites a reply. No internal essay. No named emotions. No metaphors for feelings. The character is present, not being described.\n\n`;

    p += `== HOW TO WRITE ==
- Lead with dialogue most of the time — action and thought support the words, they don't replace them
- First person ("I", "me", "my") — never slip into third-person about yourself
- Match the conversation's existing formatting — use *asterisks for action* only if the chat already does
- Mirror the length of the message you're replying to: if it's two lines, reply in two lines
- Vary sentence length: mix short punchy lines with longer ones — monotonous rhythm is the first sign of AI writing
- Show emotion through what the character does and says — never name the feeling directly
- Push the scene forward; never summarize or repeat what just happened
- End on something that opens the door: a reaction, a question, a silence with weight

== HARD BANS — NEVER WRITE ANY OF THESE ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart" / "A wave of [emotion] washed over me"
- "My heart raced / skipped / pounded" / "My breath caught" / "My pulse quickened"
- "Heat crept up my cheeks / neck" / "A warmth spread through me" / "My skin prickled"
- Blooming, unraveling, threading, flooding, or any other metaphor for a feeling happening inside the body
- Filler action beats: *blinks* / *tilts head slightly* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Three or more consecutive sentences opening the same way
- Internal monologue that just restates what the dialogue or action already showed
- Any phrase that sounds like it came from a writing-prompt template or a generic AI story
- Referring to yourself by your own character's name in narration or in dialogue — you are always "I", "me", "my". Never accidentally use your own name where the other character's name belongs`;

    return p;
  }

  // ─── ROUTE HELPER ──────────────────────────────────────────────────────────

  function isOnChatPage() {
    return /\/chats\/[^/]/.test(location.pathname);
  }

  // ─── SVG ICONS ─────────────────────────────────────────────────────────────

  const SVG_SCISSORS = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>`;
  
  const SVG_SETTINGS = `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
  
  const SVG_PERSONA   = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
  
  // ─── Remaining icon set ───────────────────────────────────────────────────

  const SVG_REPLY     = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_STYLES    = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
  const SVG_SUMMARISE = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`;
  const SVG_CONTEXT   = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`;
  const SVG_CONFIG    = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
  const SVG_INFO      = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`;
  const SVG_SPARKLE   = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>`;
  const SVG_SAVE      = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`;
  const SVG_FOLDER    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_MEMORY    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`;
  const SVG_COPY      = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
  const SVG_REROLL    = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`;
  const SVG_WARNING   = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
  const SVG_CHAT     = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
  const SVG_TRASH     = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
  const SVG_TIP       = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`;
  const SVG_ROCKET    = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`;
  const SVG_CHECK     = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="20 6 9 17 4 12"/></svg>`;
  const SVG_CROSS     = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
  const SVG_ARROW_R   = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
  const SVG_KEYBOARD  = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h.01M18 14h.01M10 14h4"/></svg>`;
  const SVG_ARROW_UP  = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`;
  const SVG_ARROW_DN  = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
  const SVG_LOCK      = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
  const SVG_UNLOCK    = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>`;
  const SVG_SHIELD    = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`;

  // ─── STYLES ────────────────────────────────────────────────────────────────

  GM_addStyle(`
    /* ── (!) Info Popover System ── */
    .jv5-info-btn {
      display: inline-flex; align-items: center; justify-content: center;
      width: 18px; height: 18px; border-radius: 50%; flex-shrink: 0;
      background: rgba(99,102,241,0.15); border: 1.5px solid rgba(99,102,241,0.45);
      color: #818cf8; font-size: 10px; font-weight: 800; cursor: pointer;
      line-height: 1; padding: 0; font-family: system-ui, sans-serif;
      transition: background 0.15s, border-color 0.15s, color 0.15s;
      vertical-align: middle; position: relative;
      /* Expand touch target to 44×44px without affecting layout */
      -webkit-tap-highlight-color: transparent;
    }
    .jv5-info-btn::after {
      content: ''; position: absolute;
      top: 50%; left: 50%; transform: translate(-50%,-50%);
      width: 44px; height: 44px; border-radius: 50%;
      pointer-events: none;
    }
    .jv5-info-btn:hover { background: rgba(99,102,241,0.3); border-color: #818cf8; color: #c7d2fe; }
    .jv5-info-btn.active { background: rgba(99,102,241,0.35); border-color: #a5b4fc; color: #e0e7ff; }
    .jv5-info-popover {
      position: absolute; z-index: 100001;
      background: #16162a; border: 1px solid rgba(99,102,241,0.5);
      border-radius: 10px; padding: 12px 14px; width: 280px; max-width: calc(100vw - 32px);
      box-shadow: 0 8px 28px rgba(0,0,0,0.65);
      font-size: 11.5px; color: #c7d2fe; line-height: 1.6;
      font-family: system-ui, sans-serif;
      animation: ms2-fade 0.15s ease;
    }
    .jv5-info-popover h4 {
      margin: 0 0 7px 0; font-size: 12px; font-weight: 700;
      color: #a5b4fc; letter-spacing: 0.03em;
      display: flex; align-items: center; gap: 6px;
    }
    .jv5-info-popover p { margin: 0 0 6px 0; color: #9ca3af; }
    .jv5-info-popover p:last-child { margin-bottom: 0; }
    .jv5-info-popover strong { color: #e0e7ff; }
    .jv5-info-popover code {
      background: rgba(99,102,241,0.2); border-radius: 3px;
      padding: 1px 5px; font-size: 10.5px; color: #a5b4fc; font-family: monospace;
    }
    .jv5-info-popover .jv5-info-tag {
      display: inline-flex; align-items: center; gap: 3px;
      background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.35);
      border-radius: 20px; padding: 1px 8px; font-size: 10px;
      font-weight: 700; color: #6ee7b7; margin-bottom: 6px;
    }
    .jv5-info-popover .jv5-info-tag.warn {
      background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.35);
      color: #fde68a;
    }
    .jv5-info-popover .jv5-info-tag.sec {
      background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.4);
      color: #c4b5fd;
    }
    .jv5-info-divider { border: none; border-top: 1px solid rgba(99,102,241,0.2); margin: 8px 0; }

    /* ── PIN / Key Encryption Modal ── */
    .jv5-pin-modal {
      position: fixed; inset: 0; z-index: 10000050;
      background: rgba(0,0,0,0.75); display: flex;
      align-items: center; justify-content: center;
      animation: ms2-fade 0.15s ease;
    }
    .jv5-pin-box {
      background: #13131f; border: 1px solid rgba(139,92,246,0.5);
      border-radius: 14px; padding: 22px 20px; width: min(340px, calc(100vw - 32px));
      box-shadow: 0 12px 36px rgba(0,0,0,0.75);
      font-family: system-ui, sans-serif;
      animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
    }
    .jv5-pin-box h3 {
      margin: 0 0 4px 0; font-size: 14px; font-weight: 700; color: #c4b5fd;
      display: flex; align-items: center; gap: 8px;
    }
    .jv5-pin-box p { font-size: 11.5px; color: #6b7280; line-height: 1.55; margin: 0 0 14px 0; }
    .jv5-pin-box input {
      width: 100%; box-sizing: border-box;
      padding: 10px 12px; background: #0d0d1a;
      border: 1px solid rgba(139,92,246,0.4); border-radius: 8px;
      color: #e2e8f0; font-size: 14px; font-family: monospace;
      outline: none; margin-bottom: 10px;
      transition: border-color 0.15s;
    }
    .jv5-pin-box input:focus { border-color: #8b5cf6; }
    .jv5-pin-box .jv5-pin-err {
      font-size: 11px; color: #f87171; margin: -6px 0 8px 0; min-height: 14px;
    }
    .jv5-pin-box .jv5-pin-row { display: flex; gap: 8px; }
    .jv5-pin-box .jv5-pin-row button {
      flex: 1; padding: 9px; font-size: 12px; font-weight: 600;
      border-radius: 8px; cursor: pointer; font-family: system-ui, sans-serif;
      transition: opacity 0.15s;
    }
    .jv5-pin-btn-confirm {
      background: linear-gradient(135deg,#7c3aed,#6d28d9);
      border: none; color: #fff;
    }
    .jv5-pin-btn-confirm:hover { opacity: 0.88; }
    .jv5-pin-btn-cancel {
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.12); color: #9ca3af;
    }
    .jv5-pin-status {
      font-size: 11px; display: flex; align-items: center; gap: 5px;
      padding: 5px 8px; border-radius: 6px; margin-bottom: 10px;
    }
    .jv5-pin-status.locked {
      background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3);
      color: #a78bfa;
    }
    .jv5-pin-status.unlocked {
      background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3);
      color: #6ee7b7;
    }
    .jv5-pin-status.off {
      background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.25);
      color: #fbbf24;
    }

    /* ── FAB ── */
    #ms2-fab {
      position: fixed; z-index: 999999;
      width: 44px; height: 44px;
      background: #1a1625; border: 1.5px solid rgba(139,92,246,0.55);
      border-radius: 50%; display: flex; align-items: center; justify-content: center;
      cursor: grab; box-shadow: 0 3px 16px rgba(0,0,0,0.55);
      color: #8b5cf6; user-select: none; touch-action: none;
      transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
      font-size: 18px;
    }
    #ms2-fab:hover { background: #221d35; box-shadow: 0 4px 24px rgba(139,92,246,0.4); }
    #ms2-fab.ms2-dragging { cursor: grabbing; opacity: 0.8; transform: scale(1.08); }
    #ms2-fab.ms2-pressing { transform: scale(0.93); }
    #ms2-fab.ms2-dial-open { background: #2d1f48; border-color: rgba(139,92,246,0.9); box-shadow: 0 4px 24px rgba(139,92,246,0.5); }
    #ms2-fab-ring {
      position: absolute; inset: -3px; border-radius: 50%;
      pointer-events: none; background: conic-gradient(rgba(139,92,246,0.7) 0%, transparent 0%);
    }

    /* ── Speed-Dial ── */
    #ms2-dial-overlay { position: fixed; inset: 0; z-index: 999997; }
    .ms2-dial-btn {
      display: flex; align-items: center; gap: 8px;
      cursor: pointer; position: fixed;
    }
    .ms2-dial-fab {
      width: 38px; height: 38px; border-radius: 50%;
      border: 1.5px solid transparent; display: flex; align-items: center;
      justify-content: center; font-size: 16px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.4);
      transition: transform 0.12s; flex-shrink: 0;
    }
    .ms2-dial-btn:hover .ms2-dial-fab { transform: scale(1.12); }
    .ms2-dial-label {
      background: #1a1625; border: 1px solid rgba(139,92,246,0.35);
      color: #c4b5fd; font-size: 11px; font-weight: 600;
      font-family: system-ui, sans-serif; padding: 4px 9px;
      border-radius: 7px; white-space: nowrap;
      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
    }
    @keyframes ms2-dial-in {
      from { opacity: 0; transform: translateY(12px) scale(0.88); }
      to   { opacity: 1; transform: translateY(0)   scale(1); }
    }
    .ms2-dial-panel {
      position: fixed; z-index: 999998;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
      border-radius: 12px; padding: 5px;
      display: flex; flex-direction: column; gap: 2px;
      box-shadow: 0 8px 28px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
      min-width: 160px;
      font-family: system-ui, sans-serif;
    }
    .ms2-dial-row {
      display: flex; align-items: center; gap: 9px;
      padding: 8px 12px; border-radius: 8px;
      background: transparent; border: none; cursor: pointer;
      color: #e2e8f0; font-size: 13px; font-weight: 500;
      text-align: left; width: 100%;
      transition: background 0.12s;
    }
    .ms2-dial-row:hover { background: rgba(139,92,246,0.14); }
    .ms2-dial-row-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
    .ms2-dial-row-label { flex: 1; white-space: nowrap; }

    /* ── Hint ── */
    #ms2-fab-hint {
      position: fixed; z-index: 999998;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
      color: #c4b5fd; font-size: 11px; font-family: system-ui, sans-serif;
      padding: 5px 10px; border-radius: 8px; white-space: nowrap;
      pointer-events: none; animation: ms2-fade 0.2s ease;
      box-shadow: 0 2px 12px rgba(0,0,0,0.4);
    }

    /* ── Backdrop / Modal ── */
    .ms2-backdrop {
      position: fixed; inset: 0; background: rgba(0,0,0,0.88);
      /* backdrop-filter:blur removed — forces full repaint every frame, main scroll-lag cause */
      z-index: 9999999;
      display: flex; align-items: center; justify-content: center;
      padding: 20px; animation: ms2-fade 0.18s ease;
    }
    @keyframes ms2-fade { from { opacity:0 } to { opacity:1 } }
    @keyframes ms2-up   { from { transform:translateY(16px);opacity:0 } to { transform:translateY(0);opacity:1 } }
    @keyframes ms2-spin { to   { transform:rotate(360deg) } }

    /* ── Persona Library cards ── */
    .ms2-pl-card {
      display: flex; align-items: flex-start; gap: 6px;
      padding: 7px 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      border-radius: 6px; transition: background 0.12s;
    }
    .ms2-pl-card:hover { background: rgba(139,92,246,0.07); }

    /* ── Persona quick-switch popup rows ── */
    .ms2-pp-row {
      display: flex; align-items: center; gap: 6px;
      padding: 5px 6px; border-radius: 6px;
      transition: background 0.1s; cursor: default;
    }
    .ms2-pp-row:hover { background: rgba(244,114,182,0.08); }

    .ms2-modal {
      background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
      border-radius: 14px; width: calc(100vw - 16px); max-width: 600px;
      max-height: min(88vh, calc(100dvh - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px) - 24px));
      display: flex; flex-direction: column; overflow: hidden;
      box-shadow: 0 12px 32px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
      animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
      font-family: system-ui, sans-serif;
      contain: content;
    }
    .ms2-modal-header {
      display: flex; align-items: center; justify-content: space-between;
      padding: 11px 14px 10px; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
    }
    .ms2-modal-title { font-size: 13px; font-weight: 600; color: #c4b5fd; display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; overflow: hidden; }
    .ms2-modal-close {
      background: none; border: none; color: #6b7280; cursor: pointer;
      font-size: 20px; line-height: 1; padding: 4px 8px; border-radius: 6px;
      transition: color 0.12s, background 0.12s; flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
    }
    .ms2-modal-close:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
    .ms2-modal-body { overflow-y: auto; padding: 12px 14px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
    .ms2-modal-body::-webkit-scrollbar { width: 4px; }
    .ms2-modal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }
    .ms2-modal-footer {
      display: flex; gap: 8px; padding: 10px 14px 12px;
      border-top: 1px solid rgba(255,255,255,0.07); flex-wrap: wrap; flex-shrink: 0;
    }
    /* Mobile: prevent backdrop overflow on small screens */
    .ms2-backdrop {
      padding: env(safe-area-inset-top,8px) 8px env(safe-area-inset-bottom,8px) !important;
      overflow-y: auto !important;
      -webkit-overflow-scrolling: touch !important;
      align-items: flex-start !important;
    }
    @media (max-height: 600px) {
      .ms2-modal { max-height: calc(100dvh - 16px); border-radius: 10px; }
      .ms2-modal-header { padding: 8px 12px; }
      .ms2-modal-body { padding: 8px 12px; }
    }

    /* ── Shared text/label/box ── */
    .ms2-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; margin-bottom: 7px; }
    .ms2-textbox {
      background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
      border-radius: 8px; padding: 11px 13px; font-size: 13px; line-height: 1.65;
      color: #d1d5db; white-space: pre-wrap; word-break: break-word;
      margin-bottom: 13px; font-family: inherit;
    }
    .ms2-textbox.result { border-color: rgba(139,92,246,0.32); color: #ede9fe; }
    .ms2-textbox-preview { max-height: 120px; overflow-y: auto; }
    .ms2-spinner {
      display: flex; align-items: center; justify-content: center;
      gap: 10px; padding: 28px; color: #8b5cf6; font-size: 13px;
    }
    .ms2-spinner::before {
      content:''; width: 18px; height: 18px;
      border: 2px solid rgba(139,92,246,0.25); border-top-color: #8b5cf6;
      border-radius: 50%; animation: ms2-spin 0.75s linear infinite; flex-shrink: 0;
    }
    .ms2-error-box {
      background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.35);
      border-radius: 8px; padding: 11px 13px; font-size: 12px; color: #fca5a5; margin-bottom: 13px;
    }
    .ms2-badge {
      display: inline-flex; align-items: center; padding: 1px 6px;
      background: rgba(139,92,246,0.22); border-radius: 4px;
      font-size: 10px; font-weight: 700; color: #a78bfa; margin-left: 5px;
    }
    .ms2-no-text {
      text-align: center; padding: 28px 20px;
      color: #6b7280; font-size: 13px; line-height: 1.6;
    }
    .ms2-no-text strong { color: #9ca3af; }

    /* ── Buttons ── */
    .ms2-btn-action {
      flex: 1; min-width: 80px; padding: 8px 12px; font-size: 12px; font-weight: 600;
      font-family: system-ui, sans-serif; border-radius: 8px; cursor: pointer; border: none;
      transition: opacity 0.15s, transform 0.1s;
    }
    .ms2-btn-action:active { transform: scale(0.97); }
    .ms2-btn-copy    { background: rgba(139,92,246,0.2); border: 1px solid rgba(139,92,246,0.45) !important; color: #c4b5fd; }
    .ms2-btn-copy:hover { background: rgba(139,92,246,0.33); }
    .ms2-btn-retry   { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.11) !important; color: #9ca3af; }
    .ms2-btn-retry:hover { background: rgba(255,255,255,0.1); }
    .ms2-sum-hist-item { background:#0d0d1a; border:1px solid #1e1b4b; border-radius:8px; padding:8px 10px; margin-bottom:6px; cursor:pointer; transition:border-color .2s; }
    .ms2-sum-hist-item:hover { border-color:#6366f1; }
    .ms2-btn-generate { background: linear-gradient(135deg,#7c3aed,#6d28d9); color: #fff; border: none !important; flex: 2; }
    .ms2-btn-generate:hover { opacity: 0.88; }
    .ms2-btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
    .ms2-btn-send    { background: rgba(6,182,212,0.2); border: 1px solid rgba(6,182,212,0.5) !important; color: #67e8f9; }
    .ms2-btn-send:hover { background: rgba(6,182,212,0.32); }

    /* ── Length picker ── */
    .ms2-length-row { display: flex; gap: 6px; margin-bottom: 12px; }
    .ms2-length-btn {
      flex: 1; padding: 7px 4px; font-size: 11px; font-weight: 600;
      border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
      background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
      transition: background 0.13s, border-color 0.13s, color 0.13s;
    }
    .ms2-length-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
    .ms2-length-btn.active { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.6); color: #c4b5fd; }

    /* ── Toggle switch ── */
    .ms2-toggle-row {
      display: flex; align-items: center; justify-content: space-between;
      padding: 8px 0; margin-bottom: 4px;
    }
    .ms2-toggle-label { font-size: 12px; color: #9ca3af; font-family: system-ui, sans-serif; }
    .ms2-toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
    .ms2-toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
    .ms2-toggle-thumb {
      position: absolute; inset: 0; border-radius: 20px;
      background: rgba(255,255,255,0.12); cursor: pointer; transition: background 0.2s;
    }
    .ms2-toggle-thumb::after {
      content: ''; position: absolute; top: 3px; left: 3px;
      width: 14px; height: 14px; border-radius: 50%;
      background: #6b7280; transition: transform 0.2s, background 0.2s;
    }
    .ms2-toggle-switch input:checked + .ms2-toggle-thumb { background: rgba(139,92,246,0.5); }
    .ms2-toggle-switch input:checked + .ms2-toggle-thumb::after { transform: translateX(16px); background: #8b5cf6; }

    /* ── Tone grid ── */
    .ms2-tone-grid {
      display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 4px;
    }
    .ms2-tone-btn {
      padding: 7px 6px; font-size: 11px; font-weight: 600; text-align: center;
      border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
      background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
      transition: background 0.13s, border-color 0.13s, color 0.13s;
    }
    .ms2-tone-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
    .ms2-tone-btn.active { background: rgba(6,182,212,0.2); border-color: rgba(6,182,212,0.55); color: #67e8f9; }

    /* ── Instruction textarea ── */
    .ms2-instruction-box {
      width: 100%; box-sizing: border-box; padding: 9px 11px; margin-bottom: 12px;
      background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
      border-radius: 8px; color: #d1d5db; font-size: 12px; font-family: system-ui, sans-serif;
      outline: none; resize: vertical; min-height: 58px;
      transition: border-color 0.15s;
    }
    .ms2-instruction-box:focus { border-color: rgba(139,92,246,0.5); }

    /* ── Active preset chip ── */
    .ms2-preset-chip {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 4px 10px; margin-bottom: 12px;
      background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4);
      border-radius: 20px; font-size: 11px; font-weight: 600; color: #fbbf24;
      font-family: system-ui, sans-serif;
    }

    /* ── Settings modal (tabbed) ── */
    .ms2-settings-v2 {
      background: #111118; border: 1px solid rgba(139,92,246,0.35);
      border-radius: 14px; width: min(500px, calc(100vw - 32px));
      max-height: 88vh; display: flex; flex-direction: column;
      font-family: system-ui, sans-serif; color: #e8e8f0;
      box-shadow: 0 12px 32px rgba(0,0,0,0.7);
      animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
      overflow: hidden;
      contain: content;
    }
    .ms2-tab-bar {
      display: flex; border-bottom: 1px solid rgba(255,255,255,0.07);
      flex-shrink: 0; overflow-x: auto; scrollbar-width: none;
    }
    .ms2-tab-bar::-webkit-scrollbar { display: none; }
    .ms2-tab {
      flex-shrink: 0; padding: 10px 12px; font-size: 11px; font-weight: 600;
      color: #6b7280; background: none; border: none; cursor: pointer;
      border-bottom: 2px solid transparent; margin-bottom: -1px;
      transition: color 0.15s, border-color 0.15s; white-space: nowrap;
      font-family: system-ui, sans-serif;
    }
    .ms2-tab:hover { color: #9ca3af; }
    .ms2-tab.active { color: #c4b5fd; border-bottom-color: #8b5cf6; }
    .ms2-tab-panel { display: none; }
    .ms2-tab-panel.active { display: block; }
    .ms2-settings-body { overflow-y: auto; padding: 16px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
    .ms2-settings-body::-webkit-scrollbar { width: 4px; }
    .ms2-settings-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }

    /* ── Settings inputs ── */
    .ms2-field-label { display: block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #7878a0; margin-bottom: 5px; }
    .ms2-input, .ms2-select {
      width: 100%; box-sizing: border-box; padding: 9px 11px;
      background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 8px;
      color: #e8e8f0; font-size: 13px; outline: none;
      transition: border-color 0.15s; margin-bottom: 13px; font-family: monospace;
    }
    .ms2-select { font-family: system-ui, sans-serif; cursor: pointer; }
    .ms2-input:focus, .ms2-select:focus { border-color: #7c3aed; }
    .ms2-textarea-sm { min-height: 72px; resize: vertical; font-family: system-ui, sans-serif; }
    .ms2-textarea-lg { min-height: 110px; resize: vertical; font-family: system-ui, sans-serif; }
    .ms2-tip {
      background: #1e1a2e; border: 1px solid #3d2d6e; border-radius: 8px;
      padding: 9px 11px; font-size: 11px; color: #9880d0; line-height: 1.5; margin-bottom: 13px;
    }
    .ms2-tip a { color: #a78bfa; }
    .ms2-settings-actions { display: flex; gap: 8px; margin-top: 4px; }
    .ms2-btn-save {
      flex: 1; padding: 10px; background: linear-gradient(135deg,#7c3aed,#6d28d9);
      border: none; border-radius: 8px; color: #fff; font-size: 13px; font-weight: 700;
      cursor: pointer; font-family: system-ui, sans-serif; transition: opacity 0.15s;
    }
    .ms2-btn-save:hover { opacity: 0.88; }
    .ms2-btn-cancel {
      padding: 10px 16px; background: #1e1e2c; border: 1px solid #2a2a3a;
      border-radius: 8px; color: #a0a0c0; font-size: 13px;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s;
    }
    .ms2-btn-cancel:hover { background: #2a2a3a; }

    /* ── Adv. Prompt ── */
    .ap-status-dot {
      display: inline-block; width: 7px; height: 7px; border-radius: 50%;
      margin-left: 6px; vertical-align: middle; flex-shrink: 0;
      background: #374151;
    }
    .ap-status-dot.ap-status-ok   { background: #22c55e; box-shadow: 0 0 5px #22c55e88; }
    .ap-status-dot.ap-status-fail { background: #ef4444; box-shadow: 0 0 5px #ef444488; }
    .ap-save-dirty { border-color: rgba(245,158,11,0.7) !important; color: #fbbf24 !important; }
    .ap-token-bar {
      height: 3px; border-radius: 2px; margin-bottom: 10px;
      background: rgba(255,255,255,0.06);
    }
    .ap-token-fill {
      height: 100%; border-radius: 2px; transition: width 0.2s;
      background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
      background-size: 300% 100%;
    }
    .ap-token-label { font-size: 10px; color: #6b7280; margin-bottom: 4px; text-align: right; }
    .ap-module-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
    .ap-module-item {
      display: flex; align-items: center; gap: 8px;
      padding: 8px 10px; border-radius: 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      cursor: default; user-select: none; transition: border-color 0.13s;
    }
    .ap-module-item:hover { border-color: rgba(139,92,246,0.35); }
    .ap-module-item.ap-disabled { opacity: 0.45; }
    .ap-module-item.ap-dragging { opacity: 0.5; border-style: dashed; }
    .ap-module-item.ap-drag-over { border-color: #8b5cf6; background: rgba(139,92,246,0.1); }
    .ap-drag-handle {
      cursor: grab; color: #4b5563; font-size: 13px; flex-shrink: 0; padding: 0 2px;
      line-height: 1;
    }
    .ap-drag-handle:active { cursor: grabbing; }
    .ap-module-name { flex: 1; font-size: 12px; color: #d1d5db; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ap-module-btns { display: flex; gap: 4px; flex-shrink: 0; }
    .ap-module-btn {
      background: none; border: none; cursor: pointer; padding: 3px 5px;
      border-radius: 5px; color: #6b7280; font-size: 12px; line-height: 1;
      transition: color 0.12s, background 0.12s;
    }
    .ap-module-btn:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
    .ap-module-btn.ap-del:hover { color: #fca5a5; }
    .ap-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; }
    .ap-select {
      flex: 1; background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 7px;
      color: #d1d5db; font-size: 12px; padding: 7px 9px; outline: none;
      transition: border-color 0.15s; cursor: pointer;
    }
    .ap-select:focus { border-color: #7c3aed; }
    .ap-icon-btn {
      flex-shrink: 0; padding: 7px 9px; background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.1); border-radius: 7px; color: #9ca3af;
      cursor: pointer; font-size: 13px; line-height: 1;
      transition: color 0.12s, background 0.12s;
    }
    .ap-icon-btn:hover { background: rgba(255,255,255,0.1); color: #e5e7eb; }
    .ap-empty { text-align: center; color: #4b5563; font-size: 12px; padding: 18px 0; }
    .ap-module-switch { position: relative; width: 30px; height: 17px; flex-shrink: 0; }
    .ap-module-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
    .ap-module-thumb {
      position: absolute; inset: 0; border-radius: 17px;
      background: rgba(255,255,255,0.1); cursor: pointer; transition: background 0.2s;
    }
    .ap-module-thumb::after {
      content: ''; position: absolute; top: 2px; left: 2px;
      width: 13px; height: 13px; border-radius: 50%;
      background: #6b7280; transition: transform 0.2s, background 0.2s;
    }
    .ap-module-switch input:checked + .ap-module-thumb { background: rgba(139,92,246,0.45); }
    .ap-module-switch input:checked + .ap-module-thumb::after { transform: translateX(13px); background: #8b5cf6; }

    /* ── Presets list ── */
    .ms2-presets-empty { padding: 16px 0; color: #6b7280; font-size: 12px; text-align: center; }
    .ms2-preset-item {
      display: flex; align-items: center; justify-content: space-between; gap: 10px;
      padding: 10px 12px; margin-bottom: 8px;
      background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
      border-radius: 9px; transition: border-color 0.15s;
    }
    .ms2-preset-item.is-active { border-color: rgba(245,158,11,0.5); background: rgba(245,158,11,0.06); }
    .ms2-preset-info { flex: 1; min-width: 0; }
    .ms2-preset-name { font-size: 12px; font-weight: 600; color: #e8e8f0; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ms2-preset-tone { font-size: 10px; color: #7878a0; }
    .ms2-preset-actions { display: flex; gap: 5px; flex-shrink: 0; }
    .ms2-preset-btn {
      padding: 5px 9px; font-size: 10px; font-weight: 600; border-radius: 6px;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.13s;
    }
    .ms2-preset-use    { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4); color: #fbbf24; }
    .ms2-preset-use:hover    { background: rgba(245,158,11,0.28); }
    .ms2-preset-active { background: rgba(245,158,11,0.35); border: 1px solid rgba(245,158,11,0.7); color: #fbbf24; }
    .ms2-preset-edit   { background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.35); color: #a78bfa; }
    .ms2-preset-edit:hover   { background: rgba(139,92,246,0.28); }
    .ms2-preset-del    { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #f87171; }
    .ms2-preset-del:hover    { background: rgba(239,68,68,0.22); }
    .ms2-btn-new-preset {
      width: 100%; padding: 9px; margin-top: 4px;
      background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15);
      border-radius: 8px; color: #6b7280; font-size: 12px; font-weight: 600;
      cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s, color 0.15s, border-color 0.15s;
    }
    .ms2-btn-new-preset:hover { background: rgba(255,255,255,0.08); color: #9ca3af; border-color: rgba(139,92,246,0.4); }

    /* ── About tab ── */
    .ms2-about-box { font-size: 12px; color: #9ca3af; line-height: 1.7; }
    .ms2-about-title { font-size: 14px; font-weight: 700; color: #c4b5fd; margin-bottom: 2px; }
    .ms2-about-version { font-size: 10px; color: #6b7280; margin-bottom: 14px; }
    .ms2-about-row { padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
    .ms2-about-row strong { color: #d1d5db; }

    /* ── Toasts ── */
    .ms2-toast {
      position: fixed; bottom: 72px; right: 20px; z-index: 10000030;
      background: #1a1625; border: 1px solid rgba(139,92,246,0.5);
      color: #c4b5fd; padding: 7px 13px; border-radius: 8px;
      font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
      box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
      pointer-events: none;
    }
    .ms2-top-toast {
      position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
      z-index: 10000030; background: #1a1625;
      border: 1px solid rgba(6,182,212,0.5); color: #67e8f9;
      padding: 7px 16px; border-radius: 20px;
      font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
      box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
      pointer-events: none; white-space: nowrap;
    }
  `);

  // ─── HELPERS ───────────────────────────────────────────────────────────────

  function escHtml(s) {
    return String(s ?? '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  // Safe toast — plain text only. Never pass user-controlled or network data to toastHTML.
  function toast(msg, ms) {
    document.querySelector('.ms2-toast')?.remove();
    const t = document.createElement('div');
    t.className = 'ms2-toast';
    t.textContent = msg;
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), ms || 2200);
    return t;
  }
  // toastHTML — for internal trusted SVG/HTML markup ONLY. Never pass external/network data.
  function toastHTML(msg, ms) {
    document.querySelector('.ms2-toast')?.remove();
    const t = document.createElement('div');
    t.className = 'ms2-toast';
    t.innerHTML = msg; // trusted internal markup only — never network/user data
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), ms || 2200);
    return t;
  }
  function topToast(msg) {
    document.getElementById('ms2-top-toast')?.remove();
    const t = document.createElement('div');
    t.id = 'ms2-top-toast';
    t.className = 'ms2-top-toast';
    t.textContent = msg;
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), 5000);
  }
  // topToastHTML — trusted internal markup only
  function topToastHTML(msg) {
    document.getElementById('ms2-top-toast')?.remove();
    const t = document.createElement('div');
    t.id = 'ms2-top-toast';
    t.className = 'ms2-top-toast';
    t.innerHTML = msg;
    document.body.appendChild(t);
    setTimeout(() => t?.remove(), 5000);
  }

  // ─── MODAL STACK — ESCAPE-TO-CLOSE ────────────────────────────────────────

  const _modalStack = [];

  // Register the Escape handler exactly once — adding it inside pushEscapeClose
  // (the previous pattern) created a new listener on every modal open, leaking
  // event handlers proportional to the number of modals opened in a session.
  document.addEventListener('keydown', e => {
    if (e.key !== 'Escape' || !_modalStack.length) return;
    for (let i = _modalStack.length - 1; i >= 0; i--) {
      const el = _modalStack[i];
      _modalStack.splice(i, 1);
      if (document.body.contains(el)) {
        el.remove();
        e.stopPropagation();
        return;
      }
    }
  }, true);
  /**
   * Pushes `backdrop` onto the modal stack so the global Escape handler can
   * close it.  Call once per modal immediately after appending it to the DOM.
   * @param {HTMLElement} backdrop - The outermost backdrop element to close.
   */
  function pushEscapeClose(backdrop) {
    _modalStack.push(backdrop);
  }

  /** Alias kept for call-site compatibility. @see pushEscapeClose */
  const addEscapeClose = pushEscapeClose;

  const BOT_ICON_SEL = SELECTOR_CONFIG.botIcon;        
  const MSG_BODY_SEL = SELECTOR_CONFIG.messageBody;     
  const VIRTUOSO_SEL = SELECTOR_CONFIG.virtuosoItemList;
  const MIN_CHARS = 80;

  let _cachedLastBotIndex = -1;
  let _cachedLastBotText  = '';

  const FALLBACK_SELECTORS = [
    '[data-message-author-role="assistant"]',
    '[data-testid*="message"]:not([data-testid*="user"])',
    '[class*="CharacterMessage"]',
    '[class*="character-message"]',
    '[class*="ai-message"]',
    '[class*="bot-message"]',
    '[class*="assistant-message"]',
    '[data-role="assistant"]',
    '.prose',
    '[class*="prose"]',
  ];

  const STRIP_SEL = [
    'button,[role="button"],svg,form,input,select,textarea',
    '[class*="action"],[class*="toolbar"],[class*="rating"],[class*="vote"]',
    '[class*="_nameIcon_"],[class*="_name_"],[class*="nameIcon"],[class*="userName"]',
    '[class*="_chatName_"],[class*="_senderName_"],[class*="_authorName_"]',
    '[class*="_characterName_"],[class*="_msgSender_"],[class*="_header_"]',
    '[class*="avatar"],[class*="Avatar"],[class*="CharacterName"],[class*="character-name"]',
    '[class*="timestamp"],[class*="messageTime"],[class*="_time_"]',
  ].join(',');

  /**
   * Returns `true` when `node` is an AI/character message rather than a user
   * message. Detection uses three independent signals in priority order so
   * the check degrades gracefully when JanitorAI changes its DOM structure:
   *
   *  1. `data-message-author-role="assistant"` — explicit semantic attribute.
   *  2. Bot-icon element present inside the node (`_nameIcon_` / `nameIcon`).
   *  3. React Fiber `memoizedProps.message.role === 'assistant'` — deep source.
   *
   * @param {Element} node - A Virtuoso list-item or any message container.
   * @returns {boolean}
   */
  function isAINode(node) {
    if (!node) return false;
    // 1. Explicit role attribute (set by JanitorAI on some builds)
    if (node.getAttribute('data-message-author-role') === 'assistant') return true;
    if (node.querySelector('[data-message-author-role="assistant"]')) return true;
    // 2. Bot/name icon present (most reliable visual signal)
    if (node.querySelector(BOT_ICON_SEL)) return true;
    // 3. React Fiber prop inspection (survives CSS class renames)
    try {
      const key = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
      if (key) {
        let f = node[key];
        for (let i = 0; f && i < 16; i++, f = f.return) {
          const role = f.memoizedProps?.message?.role ?? f.pendingProps?.message?.role;
          if (role) return role === 'assistant';
        }
      }
    } catch {}
    return false;
  }

  /**
   * Converts an HTML element's subtree into a cleaned Markdown string.
   *
   * Strips UI chrome (buttons, avatars, names, timestamps) defined in
   * `STRIP_SEL` before walking the tree.  Handles `em`, `strong`, `p`, `br`,
   * `li`, `ul`, `ol`, `pre`, `code`, and block containers via a recursive
   * walk — producing a compact but readable plain-text/Markdown hybrid
   * suitable for AI prompt input.
   *
   * @param {Element} el - The root element to extract from.
   * @returns {string} Cleaned Markdown text.
   */
  function extractMarkdown(el) {
    const clone = el.cloneNode(true);
    
    clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());


    function walk(node) {
      if (node.nodeType === 3) return node.textContent; 
      if (node.nodeType !== 1) return '';               
      const tag = node.tagName.toLowerCase();
      const inner = Array.from(node.childNodes).map(walk).join('');
      const t = inner.trim();
      switch (tag) {
        case 'em': case 'i':
          return t ? `*${t}*` : '';
        case 'strong': case 'b':
          return t ? `**${t}**` : '';
        case 'p':
          return t ? t + '\n\n' : '';
        case 'br':
          return '\n';
        case 'li':
          return t ? `- ${t}\n` : '';
        case 'ul': case 'ol':
          return t ? t + '\n' : '';
        case 'pre':
          return t ? '```\n' + t + '\n```\n\n' : '';
        case 'code':
          return t ? '`' + t + '`' : '';
        default:

          if (/^(div|article|section|blockquote|h[1-6])$/.test(tag)) {
            return t ? t + '\n\n' : '';
          }
          return inner;
      }
    }

    return walk(clone).replace(/\n{3,}/g, '\n\n').trim();

  }
  function stripNamePrefix(text) {
    if (!text) return text;
    const nlIdx = text.indexOf('\n');
    if (nlIdx === -1) return text;
    const first = text.slice(0, nlIdx).trim();
    const rest  = text.slice(nlIdx + 1).trim();

    const firstNoInitials = first.replace(/\b[A-Z]\./g, '');
    if (
      first.length > 0 && first.length <= 50 &&
      /^[A-Z\u00C0-\u017E]/.test(first) &&
      !/[.!?,;:…"'`*]/.test(firstNoInitials) &&
      rest.length >= MIN_CHARS
    ) return rest;
    return text;
  }

  // ─── LATEST AI TEXT (DOM + cached fallback) ─────────────────────────────────

  /**
   * Extracts the text of the most recent AI/character message from the DOM.
   *
   * Uses a four-tier fallback strategy so it continues working through
   * JanitorAI DOM refactors:
   *  1. Virtuoso `[data-index]` items + `isAINode` classification.
   *  2. In-memory cache (`_cachedLastBotText`) when virtuoso items scroll
   *     out of view.
   *  3. Bot-icon (`_nameIcon_`) proximity walk to find the message body.
   *  4. Known role / semantic selectors (`FALLBACK_SELECTORS`).
   *
   * @returns {string|null} Markdown-formatted message text, or `null` if no
   *   AI message with at least `MIN_CHARS` characters is found.
   */
  function getLatestAIText() {
    try {
      const items = document.querySelectorAll(VIRTUOSO_SEL);
      for (let i = items.length - 1; i >= 0; i--) {
        const node = items[i];
        const index = parseInt(node.getAttribute('data-index'), 10);
        if (!isNaN(index) && index <= _cachedLastBotIndex) break;

        if (!isAINode(node)) continue;

        const bodies = node.querySelectorAll(MSG_BODY_SEL);
        const text = bodies.length > 0
          ? Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim()
          : extractMarkdown(node);
        if (text.length >= MIN_CHARS) {
          if (!isNaN(index)) {
            _cachedLastBotIndex = index;
            _cachedLastBotText  = text;
          }
          return stripNamePrefix(text);
        }
      }
      if (_cachedLastBotText) return stripNamePrefix(_cachedLastBotText);
    } catch {  }

    // ── Fallback: virtuoso data-testid removed — find last AI message via _nameIcon_ ──
    try {
      const icons = document.querySelectorAll(SELECTOR_CONFIG.botIcon);
      for (let i = icons.length - 1; i >= 0; i--) {
        let container = icons[i].parentElement;
        for (let depth = 0; depth < 10 && container && container !== document.body; depth++) {
          const bodies = container.querySelectorAll(MSG_BODY_SEL);
          if (bodies.length > 0) {
            const text = Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim();
            if (text.length >= MIN_CHARS) return stripNamePrefix(text);
            break;
          }
          container = container.parentElement;
        }
      }
    } catch {  }

    for (const sel of FALLBACK_SELECTORS) {
      let hits;
      try { hits = Array.from(document.querySelectorAll(sel)); } catch { continue; }
      const valid = hits.filter(el => {
        const t = (el.innerText || '').trim();
        if (t.length < MIN_CHARS) return false;
        if (el.querySelector('input,textarea,select,[contenteditable]')) return false;
        if (el.closest('nav,header,footer,aside,form,[role="navigation"],[role="banner"],[role="toolbar"]')) return false;
        return true;
      });
      if (!valid.length) continue;
      const last = valid[valid.length - 1];
      return stripNamePrefix(extractMarkdown(last));
    }

    const candidates = [];
    document.querySelectorAll('div,article').forEach(el => {
      if (!el.querySelector(':scope > p')) return;
      const t = (el.innerText || '').trim();
      if (t.length < MIN_CHARS) return;
      if (el.querySelector('input,textarea,select,[contenteditable]')) return;
      if (el.closest('nav,header,footer,aside,form,[class*="card"],[class*="Card"],[class*="sidebar"],[class*="Sidebar"],[class*="profile"],[class*="Profile"]')) return;
      candidates.push(el);
    });
    if (!candidates.length) return null;
    const leaves = candidates.filter(el => !candidates.some(o => o !== el && el.contains(o)));
    if (!leaves.length) return null;
    return stripNamePrefix(extractMarkdown(leaves[leaves.length - 1]));

}
  /**
   * Injects `text` into the JanitorAI chat input and programmatically
   * submits the form.
   *
   * Tries multiple input selectors and three send strategies in order:
   *  1. `aria-label*="send"` / `type="submit"` button click.
   *  2. Synthetic `keydown Enter` event on the textarea.
   *  3. Value-change heuristic to detect whether submission occurred.
   *
   * @param {string}    text      - Text to inject.
   * @param {function=} onSuccess - Called after a successful send.
   * @param {function=} onFail    - Called when no input or send button is found.
   */
  // ── SEND ATTEMPT LOG (read by JV5 Diagnostics v6.4+) ───────────────────────
  // injectAndSend() runs asynchronously after a Reply/Smart-Reply finishes, often
  // with no UI feedback either way. If it silently fails, there was previously no
  // record of WHY by the time the user opens the diagnostic. This ring buffer
  // captures the last few attempts: which selectors matched, what path was taken
  // (button click vs Enter-key fallback), and the final outcome.
  const _SEND_LOG_MAX = 5;
  const _sendAttemptLog = [];
  function _logSendAttempt(entry) {
    entry.time = new Date().toISOString();
    _sendAttemptLog.push(entry);
    if (_sendAttemptLog.length > _SEND_LOG_MAX) _sendAttemptLog.shift();
  }

  function injectAndSend(text, onSuccess, onFail) {
    const inputSelectors = [
      'textarea[placeholder*="message" i]',
      'textarea[placeholder*="type" i]',
      'textarea[placeholder*="write" i]',
      'textarea[data-testid*="input"]',
      '[contenteditable="true"][class*="input"]',
      '[contenteditable="true"]',
      'textarea',
    ];

    let input = null, _inputSel = null;
    for (const sel of inputSelectors) {
      try {
        const found = Array.from(document.querySelectorAll(sel)).find(
          el => !el.closest('nav,header,[role="dialog"]') && el.offsetParent !== null
        );
        if (found) { input = found; _inputSel = sel; break; }
      } catch {  }
    }

    if (!input) { _logSendAttempt({ outcome: 'no-input', text: text.slice(0,80) }); onFail && onFail(); return; }

    if (input.hasAttribute('contenteditable')) {
      input.textContent = text;
      input.dispatchEvent(new Event('input', { bubbles: true }));
    } else {
      
      const desc = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
                 || Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'value');
      if (desc && desc.set) {
        desc.set.call(input, text);
      } else {
        input.value = text;
      }
      input.dispatchEvent(new Event('input',  { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    }
    input.focus();

    setTimeout(() => {
      const sendSelectors = [
        // v6.4 — `[class*="switcher"]` proved unreliable: confirmed by JV5
        // Diagnostics (click-no-effect) to match a non-send control (likely a
        // persona/theme switcher near the compose bar) with aria-label="null".
        // Try more specific candidates FIRST, keep switcher as a last resort.
        'button:has(svg path[d^="M22 2 11 13"])',          // Lucide "Send" icon
        'button:has(svg path[d^="M12 19V5"])',             // Lucide "ArrowUp" icon (circular send btn)
        'button[aria-label*="send" i]',
        'button[aria-label*="submit" i]',
        'button[data-testid*="send"]:not(:disabled)',
        'button[type="submit"]:not(:disabled)',
        'button[class*="send"]:not(:disabled)',
        'button[aria-label="button"]:not(:disabled)',
        '[class*="switcher"]:not(:disabled)',
      ];
      for (const sel of sendSelectors) {
        try {
          const btn = Array.from(document.querySelectorAll(sel)).find(
            b => !b.closest('[role="dialog"]') && b.offsetParent !== null && !b.disabled
          );
          if (btn) {
            const valBeforeClick = input.hasAttribute('contenteditable') ? input.textContent : input.value;
            const ariaLabel = btn.getAttribute('aria-label') || null;
            // Full pointer-event sequence — some React handlers bind to
            // pointerdown/mousedown rather than (or in addition to) click.
            const rect = btn.getBoundingClientRect();
            const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
            const evOpts = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy };
            try { btn.dispatchEvent(new PointerEvent('pointerdown', evOpts)); } catch {}
            btn.dispatchEvent(new MouseEvent('mousedown', evOpts));
            try { btn.dispatchEvent(new PointerEvent('pointerup', evOpts)); } catch {}
            btn.dispatchEvent(new MouseEvent('mouseup', evOpts));
            btn.click();
            // Verify the click actually did something: a real send normally
            // clears the input (or the button becomes disabled while sending).
            // If neither happens, the click likely didn't register with the

            // app's framework — record this so the diagnostic can flag it.
            setTimeout(() => {
              const valAfter = input.hasAttribute('contenteditable') ? input.textContent : input.value;
              const cleared  = valAfter === '' && valBeforeClick !== '';
              const nowDisabled = btn.disabled || btn.hasAttribute('disabled');
              _logSendAttempt({
                outcome: (cleared || nowDisabled) ? 'click-sent' : 'click-no-effect',
                inputSel: _inputSel, btnSel: sel, ariaLabel,
                textLen: text.length, cleared, nowDisabled,
              });
            }, 400);
            onSuccess && onSuccess();
            return;
          }
        } catch {  }
      }
      
      const valBefore = input.value;
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
      setTimeout(() => {
        
        const submitted = input.value === '' || input.value !== valBefore;
        _logSendAttempt({
          outcome: submitted ? 'enter-sent' : 'enter-no-effect',
          inputSel: _inputSel, btnSel: null, textLen: text.length,
        });
        if (submitted) { onSuccess && onSuccess(); } else { onFail && onFail(); }
      }, 200);
    }, 150);

  // ─── AI MESSAGE EDITOR ─────────────────────────────────────────────────────

}
  /**
   * Attempts to edit the last AI message in-place via JanitorAI's edit UI.
   *
   * Simulates the user flow: hover → click edit pencil → overwrite textarea
   * → click save.  Falls back via `onFail` when any step's element is absent
   * (e.g. the edit button hasn't rendered yet or the selectors have changed).
   *
   * @param {string}    newText   - Replacement text to write into the edit textarea.
   * @param {function=} onSuccess - Called after save button is clicked.
   * @param {function=} onFail    - Called when an edit element cannot be found.
   */
  function replaceLatestAIMessage(newText, onSuccess, onFail) {
    const allNodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
    let lastAINode = null;
    for (let i = allNodes.length - 1; i >= 0; i--) {
      if (isAINode(allNodes[i])) {
        lastAINode = allNodes[i];
        break;
      }
    }
    if (!lastAINode) { onFail && onFail(); return; }

    lastAINode.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
    lastAINode.dispatchEvent(new MouseEvent('mouseover',  { bubbles: true }));

    setTimeout(() => {
      const editBtnSel = [
        'button[aria-label*="edit" i]',
        'button[title*="edit" i]',
        '[class*="_edit_"]',
        '[class*="editBtn"]',
        '[class*="edit-btn"]',
      ].join(',');
      const editBtn = lastAINode.querySelector(editBtnSel);
      if (!editBtn) { onFail && onFail(); return; }

      editBtn.click();

      setTimeout(() => {
        const ta = lastAINode.querySelector('textarea')
                || document.querySelector('[data-testid*="edit"] textarea')
                || document.querySelector('.edit-message textarea');
        if (!ta) { onFail && onFail(); return; }

        const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
        if (desc && desc.set) {
          desc.set.call(ta, newText);
        } else {
          ta.value = newText;
        }
        ta.dispatchEvent(new Event('input',  { bubbles: true }));
        ta.dispatchEvent(new Event('change', { bubbles: true }));
        ta.focus();

        setTimeout(() => {
          const saveBtnSel = [
            'button[aria-label*="save" i]',
            'button[aria-label*="confirm" i]',
            'button[title*="save" i]',
            '[class*="_save_"]:not(:disabled)',
            '[class*="saveBtn"]:not(:disabled)',
          ].join(',');
          const saveBtn = lastAINode.querySelector(saveBtnSel)
                       || document.querySelector(saveBtnSel);
          if (saveBtn && !saveBtn.disabled) {
            saveBtn.click();
            onSuccess && onSuccess();
          } else {
            onFail && onFail();
          }
        }, 250);
      }, 400);
    }, 150);
  }

  // ─── AUTH HEADER BUILDER ────────────────────────────────────────────────────
  // Supports: Bearer (standard OpenAI-compatible), raw (no prefix — LiteRouter,
  // some self-hosted proxies), x-api-key (Anthropic-style proxies).
  // 'auto' detects from the key shape: pure hex ≥ 32 chars → raw; everything
  // else → Bearer.  User can override at any time via the Auth Format selector.


  /**
   * Builds the HTTP `Authorization` (or `x-api-key`) header object for a
   * provider API call.
   *
   * Mode resolution (highest → lowest priority):
   *  1. `modeOverride` argument.
   *  2. `CFG.authMode` setting.
   *  3. `'auto'` → resolves to `'bearer'` for all keys.
   *
   * Anthropic native (`api.anthropic.com`) always uses `x-api-key` +
   * `anthropic-version` regardless of mode — the Anthropic API rejects
   * Bearer tokens.
   *
   * OpenRouter calls receive extra `HTTP-Referer` and `X-Title` attribution
   * headers required by the platform's usage policy.
   *
   * @param {string}  key           - Raw API key string.
   * @param {string}  ep            - Base endpoint URL (used for provider detection).
   * @param {string=} modeOverride  - Optional explicit auth mode.
   * @returns {object} Header key-value pairs ready to spread into a fetch call.
   */
  function _buildAuthHeaders(key, ep, modeOverride) {
    const headers = {};
    const mode = modeOverride || CFG.authMode || 'auto';

    if (ep && ep.includes('anthropic.com')) {
      // Anthropic native always uses x-api-key + version header
      headers['x-api-key']         = key;
      headers['anthropic-version'] = '2023-06-01';
      return headers;
    }

    let resolved = mode;
    if (mode === 'auto') {
      // Heuristic key-format detection for common proxy types:
      //   • lorebary.com / LiteRouter — 64-char lowercase hex key → x-api-key header
      //   • Anthropic sk-ant- prefix   → handled above (already returned)
      //   • Everything else            → Bearer (OpenRouter, OpenAI, xAI, Groq, Mistral…)
      const isHex64 = /^[0-9a-f]{64}$/i.test(key);
      const isLiterouter = ep && (ep.includes('lorebary.com') || ep.includes('literouter'));
      if (isHex64 || isLiterouter) {
        resolved = 'x-api-key';
      } else {
        resolved = 'bearer';
      }
    }

    if (resolved === 'x-api-key') {
      headers['x-api-key'] = key;
    } else if (resolved === 'raw') {
      headers['Authorization'] = key;
    } else {
      // 'bearer' — standard
      headers['Authorization'] = `Bearer ${key}`;
    }

    if (ep && ep.includes('openrouter')) {
      headers['HTTP-Referer'] = 'https://janitorai.com';
      headers['X-Title']      = 'JanitorV5 RP Toolkit';
    }
    return headers;

  // ─── API CALL ──────────────────────────────────────────────────────────────

}
  /**
   * Calls the configured AI provider with a system+user message pair.
   *
   * Handles both OpenAI-compatible endpoints (POST `/chat/completions`) and
   * the Anthropic native API (POST `/v1/messages`) transparently — the
   * `isAnthropic` flag switches body shape and response extraction path.
   *
   * @param {string} systemPrompt  - Content for the system / instruction role.
   * @param {string} userContent   - Content for the user turn.
   * @param {object} [opts]        - Optional overrides:
   *   `max_tokens` {number}, `temperature` {number}, `signal` {AbortSignal}.
   * @returns {Promise<string>} Trimmed completion text.
   * @throws {Error} On HTTP errors, empty responses, or network failures.
   */
  async function callAPI(systemPrompt, userContent, opts = {}) {
    if (!CFG.apiKey) throw new Error('No API key set. Long-press the FAB to open Settings → General.');
    const baseEp = CFG.endpoint.replace(/\/$/, '');

    // ── Endpoint safety checks ──────────────────────────────────────────────
    // 1. Must be HTTPS — reject plain HTTP to prevent credential exposure in transit.
    // 2. Must not point at a private/loopback/link-local address (SSRF guard).
    if (!/^https:\/\/.+/.test(baseEp)) {
      throw new Error('Custom endpoint must use HTTPS. Update it in Settings → General.');
    }
    if (_SSRF_BLOCK_RE.test(baseEp)) {
      throw new Error('Custom endpoint points to a private/local address — blocked for security.');
    }

    // ── Endpoint-key binding check ──────────────────────────────────────────
    // If the key was bound to a specific endpoint this session (PIN active) and
    // the current endpoint has since been changed, refuse the request. This
    // blocks an attacker who edits GM storage mid-session to redirect the key
    // to a harvesting server. The user must re-lock and re-unlock the PIN to
    // rebind the key to the new endpoint.
    if (_pinSession.active && _sessionEndpointBinding && baseEp !== _sessionEndpointBinding.replace(/\/$/, '')) {
      throw new Error(
        'Endpoint changed since PIN unlock. Re-lock and unlock your PIN to rebind your key to the new endpoint.'
      );
    }
    const isAnthropic  = baseEp.includes('anthropic.com');

    // ── Build headers ───────────────────────────────────────────────────────
    const headers = {
      'Content-Type': 'application/json',
      ..._buildAuthHeaders(CFG.apiKey, baseEp),
    };

    // ── Build endpoint & body ───────────────────────────────────────────────
    // Anthropic: POST /v1/messages  (system is a top-level field, not in messages array)
    // Everyone else: POST /chat/completions  (OpenAI-compatible)
    const ep = isAnthropic
      ? `${baseEp}/v1/messages`
      : `${baseEp}/chat/completions`;

    const body = isAnthropic
      ? JSON.stringify({
          model:       CFG.model,
          system:      systemPrompt,
          messages:    [{ role: 'user', content: userContent }],
          max_tokens:  opts.max_tokens  ?? 1400,
          temperature: opts.temperature ?? 0.82,
        })
      : JSON.stringify({
          model:       CFG.model,
          messages:    [
            { role: 'system', content: systemPrompt },
            { role: 'user',   content: userContent  },
          ],
          max_tokens:  opts.max_tokens  ?? 1400,
          temperature: opts.temperature ?? 0.82,
        });

    const res = await gmFetch(ep, { method: 'POST', headers, signal: opts.signal ?? undefined, body });

    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      let msg = `API error ${res.status}`;
      try {
        const errJson = JSON.parse(errText);
        // Anthropic wraps errors in { error: { message } }; OpenAI does too
        msg = errJson?.error?.message || errJson?.message || msg;
      } catch { }
      throw new Error(msg);
    }

    const data = await res.json();
    // Anthropic: data.content[0].text  |  OpenAI-compat: data.choices[0].message.content
    const result = isAnthropic
      ? (data?.content?.[0]?.text ?? '').trim()
      : (data?.choices?.[0]?.message?.content ?? '').trim();

    if (!result) throw new Error('API returned an empty response. Try a different model.');
    return result;
  }

  // ─── NEW MESSAGE OBSERVER ──────────────────────────────────────────────────

  let _observer = null;
  let _lastSeenText = '';
  function startObserver() {
    if (_observer) return;
    let _retries = 0;
    const MAX_RETRIES = 20; 
    const tryStart = () => {
      
      if (!isOnChatPage() || _retries++ >= MAX_RETRIES) return;
      const container = _findRealScroller() ||
        document.querySelector('[class*="_messagesMain_"]') ||
        document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement;
      if (!container) { setTimeout(tryStart, 1500); return; }

      let _notifyTimer = null;
      _observer = new MutationObserver(() => {
        clearTimeout(_notifyTimer);
        _notifyTimer = setTimeout(() => {
          if (!CFG.autoNotify) return;
          const text = getLatestAIText();
          if (text && text !== _lastSeenText && text.length > 20) {
            _lastSeenText = text;
            topToast(`New message ↓ — tap ${SVG_REPLY} to reply`);
          }
        }, 300);
      });
      _observer.observe(container, { childList: true, subtree: true });
    };
    tryStart();

}
  function stopObserver() {
    if (_observer) { _observer.disconnect(); _observer = null; }
    _lastSeenText = '';
  }

  // ─── AUTO-SUMMARY ENGINE ───────────────────────────────────────────────────
  /**
   * Collects all visible chat messages into a `{role, text}` array.
   *
   * Uses the Virtuoso item list as the primary source; falls back to scanning
   * `_messagesMain_` + `_messageBody_` elements when Virtuoso's
   * `data-testid` attribute has been removed by a JanitorAI update.
   * Deduplicates by text content to avoid double-counting split renders.
   *
   * @returns {Array<{role:'ai'|'user', text:string}>}
   */
  function scrapeChatMessages() {
    const nodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
    const results = [];
    const seen    = new Set();

    // ── Virtuoso path ──────────────────────────────────────────────────────
    if (nodes.length > 0) {
      for (const node of nodes) {
        const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
        let text = bodyEls.length > 0
          ? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
          : '';
        if (!text) {
          const clone = node.cloneNode(true);
          clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
          text = (clone.innerText || clone.textContent || '').trim();
        }
        if (!text || text.length < 6 || seen.has(text)) continue;
        seen.add(text);
        results.push({ role: isAINode(node) ? 'ai' : 'user', text });
      }
      return results;
    }

    // ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
    try {
      const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
      if (!main) return results;
      // Find all _messageBody_ elements and walk up to their message container
      // (the shallowest ancestor under main that holds _messageBody_ but whose
      //  parent does NOT also contain those same bodies)
      const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
      const containerMap = new Map();
      for (const body of allBodies) {
        let el = body;
        while (el.parentElement && el.parentElement !== main) {
          el = el.parentElement;
        }
        if (!containerMap.has(el)) containerMap.set(el, []);
        containerMap.get(el).push(body);
      }
      for (const [container, bodies] of containerMap) {
        const text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim()
          || (() => {
               const clone = container.cloneNode(true);
               clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
               return (clone.innerText || clone.textContent || '').trim();
             })();
        if (!text || text.length < 6 || seen.has(text)) continue;
        seen.add(text);
        const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
        results.push({ role: isAI ? 'ai' : 'user', text });
      }
    } catch {  }

    return results;

}
  function buildSumPrompt(msgs) {
    const charLimit = 2200;
    
    const snippet = msgs.slice(-80)
      .map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 350)}`)
      .join('\n');
    const systemLines = [
      'You are a roleplay session analyst. Write a structured summary using EXACTLY this format — no preamble, no extra labels, no deviation:',
      '',
      '[Character A] and [Character B] are in [location]. The setting is [mood/atmosphere in one short clause].',
      '',
      'The most important things that happened are:',
      '',
      '1. [Most important event — one sentence, active voice, use character names]',
      '2. [Second event]',
      '3. [Third event]',
      '4. [Fourth event, if applicable]',
      '5. [Fifth event, if applicable]',
      '',
      'Unresolved tension or emotional subtext: [One or two sentences about unresolved feelings, unclear dynamics, or open threads.]',
      '',
      'RULES:',
      '- Use character names exactly as they appear in the log.',
      '- 3 to 5 numbered items — never fewer than 3.',
      '- Do NOT quote dialogue. Extract what happened, not what was said word for word.',
      '- Do NOT use flowery or literary language — keep it clear and factual.',
      '- Do NOT write anything before the first sentence or after the "Unresolved" line.',
      `- Hard limit: ${charLimit} characters total.`,
    ];
    const system = systemLines.join('\n');
    const user = `CONVERSATION LOG (${msgs.length} messages, last 80 shown):\n${snippet}\n\nSUMMARY (follow the exact format above — start immediately with the characters + location sentence):`;
    return { system, user, charLimit };

  // ─── MEMORY BOX PROMPT BUILDER ─────────────────────────────────────────────

}
  function buildMemoryBoxPrompt(msgs) {
    
    const charLimit = 1400;
    
    const snippet = msgs.slice(0, 120)
      .map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 400)}`)
      .join('\n');

    const systemLines = [
      'You are writing a persistent memory entry for a JanitorAI chat memory box. This is NOT a scene summary — it is a stable reference note about who the characters are and what defines their relationship. A player will paste this into JanitorAI\'s built-in "Chat Memory" panel so the AI always remembers context between sessions.',
      '',
      'Write using EXACTLY this format — no preamble, no deviation:',
      '',
      'Characters: [Character A] and [Character B]. [1–2 sentences: who they each are — name, personality, their role in the story.]',
      '',
      'Relationship: [1–2 sentences: how they know each other, the nature of their bond, any key dynamic or tension between them.]',
      '',
      'Key events:',
      '1. [Most significant event that shaped the relationship — one sentence, active voice]',
      '2. [Second defining event]',
      '3. [Third defining event]',
      '4. [Fourth event, if applicable]',
      '5. [Fifth event, if applicable]',
      '',
      'Ongoing: [1–2 sentences: what is unresolved, what drives the story forward, emotional undercurrents.]',
      '',
      'RULES:',
      '- Use character names exactly as they appear in the log.',
      '- 3 to 5 numbered key events — never fewer than 3.',
      '- Write in present tense where natural ("They share a complicated past…").',
      '- Do NOT quote dialogue verbatim. Describe what happened.',
      '- Do NOT describe the current scene or what just happened — focus on lasting facts.',
      '- Do NOT use flowery language — clear, factual, compact.',
      '- Do NOT write anything outside the four sections above.',
      `- Hard limit: ${charLimit} characters total.`,
    ];
    const system = systemLines.join('\n');
    const user = `CONVERSATION LOG (${msgs.length} messages total, up to 120 shown):\n${snippet}\n\nMEMORY ENTRY (follow the exact four-section format — start immediately with "Characters:"):`;
    return { system, user, charLimit };

  // ─── LOAD ALL — STANDALONE ─────────────────────────────────────────────────

}
  /**
   * Scrolls the chat viewport from top to bottom in steps, harvesting all
   * Virtuoso-rendered message nodes into `_loadedMap` along the way.
   *
   * Uses a stuck-round counter (3 consecutive passes with zero new messages)
   * as the termination signal rather than a fixed scroll target — this handles
   * chats of any length without over-scrolling.
   *
   * @param {function=} onProgress - Optional `(loaded, steps) => void` callback
   *   called after each harvest step.
   * @returns {Promise<number>} Total messages accumulated, or `-1` if the
   *   scroll container could not be found.
   */
  /** Returns a stable CSS selector string for a DOM element (used for diagnostics). */
  function _buildScrollerSel(el) {
    if (!el) return '';
    if (el.id) return '#' + el.id;
    const tid = el.getAttribute('data-testid');
    if (tid) return `[data-testid="${tid}"]`;
    const modCls = Array.from(el.classList).find(c => /^_[A-Za-z]/.test(c));
    if (modCls) { const m = modCls.match(/^_([A-Za-z][A-Za-z0-9]*)_/); if (m) return `[class*="${m[1]}"]`; }
    const plain = Array.from(el.classList).filter(c => c.length > 3 && !/^\d/.test(c)).sort((a,b) => b.length-a.length)[0];
    if (plain) return `[class*="${plain}"]`;
    return el.tagName.toLowerCase();
  }

  /**
   * Finds the real scrollable container for the chat message list.
   * JanitorAI sometimes wraps _messagesMain_ in a parent that does the
   * actual overflow scrolling — if _messagesMain_ has scrollHeight === clientHeight
   * (ratio 1.0) it is NOT the real scroller; walk up the DOM to find it.
   */
  function _findRealScroller() {
    // Candidates in priority order
    const candidates = [
      document.querySelector('[data-testid="virtuoso-scroller"]'),
      document.querySelector('[class*="_messagesMain_"]'),
      document.querySelector('[class*="messagesMain"]'),
      document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement,
    ].filter(Boolean);

    // First try: find one that actually has overflow to scroll
    for (const el of candidates) {
      if (el.scrollHeight > el.clientHeight + 10) return el;
    }

    // Walk up from _messagesMain_ to find the real scrollable ancestor
    const base = candidates[0] || candidates[1];
    if (base) {
      let el = base.parentElement;
      while (el && el !== document.body) {
        const style = getComputedStyle(el);
        const hasScroll = ['auto', 'scroll', 'overlay'].includes(style.overflowY);
        if (hasScroll && el.scrollHeight > el.clientHeight + 10) return el;
        el = el.parentElement;
      }
    }

    // Last resort: any scrollable element containing [data-index]
    for (const el of document.querySelectorAll('div')) {
      const style = getComputedStyle(el);
      if (!['auto', 'scroll', 'overlay'].includes(style.overflowY)) continue;
      if (el.scrollHeight <= el.clientHeight + 10) continue;
      if (el.offsetHeight < 100) continue;
      if (el.querySelector('[data-index]')) return el;
    }

    return null;
  }

  async function doLoadAll(onProgress) {
    const scroller = _findRealScroller();
    if (!scroller) return -1;

    clearAccumulated();
    const wait = ms => new Promise(r => setTimeout(r, ms));

    const harvest = () => {
      let added = 0;
      const seen = new Set();

      // ── Virtuoso path ──────────────────────────────────────────────────
      const nodes = document.querySelectorAll(VIRTUOSO_SEL);
      if (nodes.length > 0) {
        for (const node of nodes) {
          const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
          let text = bodyEls.length > 0
            ? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
            : '';
          if (!text) {
            const clone = node.cloneNode(true);
            clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
            text = (clone.innerText || clone.textContent || '').trim();
          }
          if (!text || text.length < 6) continue;
          const key = _hashStr(text.slice(0, 120));
          if (seen.has(key) || _loadedMap.has(key)) continue;
          seen.add(key);
          _loadedMap.set(key, { role: isAINode(node) ? 'ai' : 'user', text });
          _loadedOrder.push(key);
          added++;
        }
        return added;
      }

      // ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
      try {
        const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
        if (!main) return added;
        const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
        const containerMap = new Map();
        for (const body of allBodies) {
          let el = body;
          while (el.parentElement && el.parentElement !== main) el = el.parentElement;
          if (!containerMap.has(el)) containerMap.set(el, []);
          containerMap.get(el).push(body);
        }
        for (const [container, bodies] of containerMap) {
          let text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim();
          if (!text) {
            const clone = container.cloneNode(true);
            clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
            text = (clone.innerText || clone.textContent || '').trim();
          }
          if (!text || text.length < 6) continue;
          const key = _hashStr(text.slice(0, 120));
          if (seen.has(key) || _loadedMap.has(key)) continue;
          seen.add(key);
          const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
          _loadedMap.set(key, { role: isAI ? 'ai' : 'user', text });
          _loadedOrder.push(key);
          added++;
        }
      } catch {  }

      return added;
    };

    scroller.scrollTop = 0;
    scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
    await wait(1800);
    harvest();
    onProgress?.(_loadedMap.size, 0);

    const PAGE = Math.max(scroller.clientHeight * 0.75, 200);
    const MAX_STEPS = 80;
    let steps = 0;
    let stuckRounds = 0;

    while (steps < MAX_STEPS) {
      const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 20;
      scroller.scrollTop += PAGE;
      scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
      await wait(700);
      const added = harvest();
      steps++;
      onProgress?.(_loadedMap.size, steps);
      if (added === 0) {
        stuckRounds++;
        if (stuckRounds >= 3 || atBottom) break;
      } else {
        stuckRounds = 0;
      }
    }

    return _loadedMap.size;
  }

  let _sumRunning = false;
  /**
   * Generates a structured scene-context summary from currently visible
   * messages and saves it to the per-chat Scene Context field.
   *
   * Runs `buildSumPrompt` → `callAPI` → truncates to `charLimit` →
   * persists via `saveContext` + `addSumHistory`.  The UI textarea is
   * updated live if the settings modal is open.
   *
   * Guards against concurrent runs with `_sumRunning`.
   *
   * @param {object}  [opts]
   * @param {boolean} [opts.silent=false] - Suppress toast notifications when
   *   called from the auto-summary observer.
   * @returns {Promise<void>}
   */
  async function doGenerateSummary({ silent = false } = {}) {
    if (_sumRunning) return;

    const msgs = scrapeChatMessages();
    if (msgs.length < 1) {
      if (!silent) toastHTML(`${SVG_WARNING} No messages visible — scroll to the area you want to summarise.`);
      return;
    }
    _sumRunning = true;
    const genBtn = document.querySelector('#ms2-ctx-gen-btn');
    if (genBtn) { genBtn.disabled = true; genBtn.textContent = '…'; }
    try {
      const { system, user, charLimit } = buildSumPrompt(msgs);
      const raw = await callAPI(system, user, { max_tokens: 600, temperature: 0.35 });
      const summary = raw.trim().slice(0, charLimit);
      
      saveContext(summary);
      addSumHistory(summary);
      
      const ta = document.querySelector('#ms2-s-context');
      if (ta) {
        ta.value = summary;
        ta.dispatchEvent(new Event('input', { bubbles: true }));
      }
      
      renderSumHistory();
      if (!silent) toastHTML(`${SVG_CHECK} Scene Context updated from ${msgs.length} visible messages`);
    } catch (err) {
      if (!silent) toastHTML(`${SVG_WARNING} Summary failed: ${escHtml(err.message)}`, 3500);
    } finally {
      _sumRunning = false;
      if (genBtn) { genBtn.disabled = false; genBtn.innerHTML = `${SVG_SPARKLE} Generate`; }
    }
  }

  let _sumHistShowAll = false;
  function renderSumHistory() {
    const list = document.querySelector('#ms2-ctx-hist-list');
    if (!list) return;
    const allHist = getSumHistory();
    const curKey  = ctxKey();
    const curHist = allHist.filter(h => h.conv === curKey);

    const toggleId = 'ms2-sumhist-toggle';
    let toggle = list.previousElementSibling?.id === toggleId
      ? list.previousElementSibling
      : null;
    if (!toggle) {
      toggle = document.createElement('div');
      toggle.id = toggleId;
      toggle.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;';
      list.parentNode?.insertBefore(toggle, list);
    }
    const showAllCount  = allHist.length;
    const showCurCount  = curHist.length;
    toggle.innerHTML = `
      <span style="font-size:11px;color:#6b7280;flex:1;">
        ${_sumHistShowAll
          ? `All chats · <strong style="color:#a78bfa;">${showAllCount}</strong> entr${showAllCount !== 1 ? 'ies' : 'y'}`
          : `This chat · <strong style="color:#10b981;">${showCurCount}</strong> entr${showCurCount !== 1 ? 'ies' : 'y'}`}
      </span>
      <button id="ms2-sumhist-toggle-btn" style="font-size:10px;background:none;border:1px solid #374151;border-radius:4px;color:#9ca3af;padding:2px 7px;cursor:pointer;white-space:nowrap;">
        ${_sumHistShowAll ? 'This chat only' : `All chats (${showAllCount})`}
      </button>`;
    toggle.querySelector('#ms2-sumhist-toggle-btn')?.addEventListener('click', () => {
      _sumHistShowAll = !_sumHistShowAll;
      renderSumHistory();
    });

    const hist = _sumHistShowAll ? allHist : curHist;

    const idxMap = _sumHistShowAll
      ? allHist.slice(0, 20).map((_, i) => i)
      : allHist.reduce((acc, item, i) => { if (item.conv === curKey) acc.push(i); return acc; }, []);

    if (!hist.length) {
      list.innerHTML = _sumHistShowAll
        ? '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No history yet — generate to save one.</div>'
        : '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No summaries for this chat yet.<br>Switch to "All chats" to see summaries from other characters.</div>';
      return;
    }

    list.innerHTML = hist.slice(0, 10).map((item, localI) => {
      const realIdx = idxMap[localI] ?? localI;
      const isOtherChat = item.conv !== curKey;

      const otherName   = isOtherChat
        ? (getChatName(item.conv) || item.chatName || 'other chat')
        : '';
      const otherBadge  = isOtherChat
        ? `<span style="font-size:9px;background:#1e3a5f;color:#7dd3fc;border-radius:3px;padding:1px 5px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;" title="From: ${escHtml(otherName)}">${escHtml(otherName)}</span>`
        : '';
      return `<div class="ms2-sum-hist-item" data-idx="${realIdx}" style="margin-bottom:6px;background:${isOtherChat ? '#1a2332' : '#1e293b'};border-radius:6px;padding:6px 8px;${isOtherChat ? 'border-left:2px solid #1e3a5f;' : ''}">
         <div style="display:flex;align-items:center;gap:4px;margin-bottom:2px;">
           <span style="color:#6b7280;font-size:10px;flex:1;">${escHtml(item.date)}${otherBadge}</span>
           <button class="ms2-hist-edit-btn ap-icon-btn" data-idx="${realIdx}" title="Edit this entry" style="font-size:12px;padding:1px 4px;">✎</button>
           <button class="ms2-hist-del-btn ap-icon-btn" data-idx="${realIdx}" title="Delete this entry" style="font-size:12px;padding:1px 4px;color:#fca5a5;">${SVG_TRASH}</button>
         </div>
         <div class="ms2-sum-hist-body" data-idx="${realIdx}" style="color:#94a3b8;font-size:11.5px;line-height:1.4;cursor:pointer;" title="Click to load into context field">
           ${escHtml(item.text.slice(0, 120))}${item.text.length > 120 ? '…' : ''}
         </div>
       </div>`;
    }).join('');

    list.querySelectorAll('.ms2-sum-hist-body').forEach(el => {
      el.addEventListener('click', () => {
        const item = getSumHistory()[+el.dataset.idx];
        if (!item) return;
        const ta = document.querySelector('#ms2-s-context');
        if (ta) { ta.value = item.text; ta.dispatchEvent(new Event('input', { bubbles: true })); }
        toast('↩ History entry loaded into context field');
      });
    });

    list.querySelectorAll('.ms2-hist-edit-btn').forEach(btn => {
      btn.addEventListener('click', e => {
        e.stopPropagation();
        const idx = +btn.dataset.idx;
        const row = btn.closest('.ms2-sum-hist-item');
        if (row.querySelector('.ms2-hist-edit-wrap')) return; 
        const item = getSumHistory()[idx];
        if (!item) return;
        const wrap = document.createElement('div');
        wrap.className = 'ms2-hist-edit-wrap';
        wrap.style.cssText = 'margin-top:6px;';
        const ta = document.createElement('textarea');
        ta.value = item.text;
        ta.style.cssText = 'width:100%;box-sizing:border-box;min-height:64px;max-height:140px;font-size:11px;background:#0f172a;color:#e2e8f0;border:1px solid #475569;border-radius:4px;padding:5px;resize:vertical;overflow-y:auto;';
        
        ta.addEventListener('wheel', e => e.stopPropagation(), { passive: true });
        ta.addEventListener('touchmove', e => e.stopPropagation(), { passive: true });
        const actRow = document.createElement('div');
        actRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
        const saveBtn = document.createElement('button');
        saveBtn.innerHTML = `${SVG_CHECK} Save`;
        saveBtn.className = 'ap-icon-btn';
        saveBtn.style.cssText = 'color:#86efac;font-size:12px;padding:3px 8px;';
        const cancelBtn = document.createElement('button');
        cancelBtn.textContent = 'Cancel';
        cancelBtn.className = 'ap-icon-btn';
        cancelBtn.style.cssText = 'font-size:12px;padding:3px 8px;';
        actRow.append(saveBtn, cancelBtn);
        wrap.append(ta, actRow);
        row.appendChild(wrap);
        ta.focus();
        saveBtn.addEventListener('click', () => {
          const newText = ta.value.trim();
          if (!newText) { ta.focus(); return; }
          const h = getSumHistory();
          if (!h[idx]) return;
          h[idx].text = newText;
          saveSumHistory(h);
          renderSumHistory();
          toastHTML(`${SVG_CHECK} Summary entry updated`);
        });
        cancelBtn.addEventListener('click', () => wrap.remove());
      });
    });

    list.querySelectorAll('.ms2-hist-del-btn').forEach(btn => {
      btn.addEventListener('click', e => {
        e.stopPropagation();
        const idx = +btn.dataset.idx;
        const row = btn.closest('.ms2-sum-hist-item');
        
        const existing = row.querySelector('.ms2-hist-confirm');
        if (existing) { existing.remove(); return; }
        const bar = document.createElement('div');
        bar.className = 'ms2-hist-confirm';
        bar.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:6px;font-size:11px;';
        const label = document.createElement('span');
        label.textContent = 'Delete this entry?';
        label.style.cssText = 'flex:1;color:#f87171;';
        const yesBtn = document.createElement('button');
        yesBtn.innerHTML = `${SVG_CHECK} Yes`;
        yesBtn.className = 'ap-icon-btn';
        yesBtn.style.color = '#f87171';
        const noBtn = document.createElement('button');
        noBtn.textContent = 'No';
        noBtn.className = 'ap-icon-btn';
        bar.append(label, yesBtn, noBtn);
        row.appendChild(bar);
        yesBtn.addEventListener('click', () => {
          const h = getSumHistory();
          h.splice(idx, 1);
          saveSumHistory(h);
          renderSumHistory();
          toast('Entry deleted');
        });
        noBtn.addEventListener('click', () => bar.remove());
      });
    });
  }

  let _sumObserver  = null;
  let _sumLastCount = 0;
  let _sumTimer     = null;
  /**
   * Starts a MutationObserver that fires `doGenerateSummary` (or a nudge
   * toast) each time the Virtuoso message count crosses a multiple of
   * `getAutoSumEvery()`.
   *
   * No-ops when the setting is 0 or when the observer is already running.
   * Retries up to 20 times (1.5 s apart) while waiting for the chat container
   * to appear in the DOM.
   */
  function startAutoSumObserver() {
    if (_sumObserver) return;
    const every = getAutoSumEvery();
    if (!every || every <= 0) return;
    let _retries = 0;
    const tryStart = () => {
      if (!isOnChatPage() || _retries++ >= 20) return;
      const container = _findRealScroller() ||
        document.querySelector('[class*="_messagesMain_"]') ||
        document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement;
      if (!container) { setTimeout(tryStart, 1500); return; }

      _sumLastCount = _countVirtuosoItems();
      _sumObserver = new MutationObserver((mutations) => {
        clearTimeout(_sumTimer);
        _sumTimer = setTimeout(() => {

          let maxSeen = _sumLastCount;
          for (const m of mutations) {
            for (const node of m.addedNodes) {
              if (node.nodeType !== 1) continue;
              const idx = parseInt(node.getAttribute?.('data-index'), 10);
              if (!isNaN(idx) && idx > maxSeen) maxSeen = idx;
              
              if (node.querySelectorAll) {
                node.querySelectorAll('[data-index]').forEach(el => {
                  const i = parseInt(el.getAttribute('data-index'), 10);
                  if (!isNaN(i) && i > maxSeen) maxSeen = i;
                });
              }
            }
          }
          if (maxSeen <= _sumLastCount) return;
          const crossed = maxSeen >= every &&
            Math.floor(maxSeen / every) > Math.floor(_sumLastCount / every);
          _sumLastCount = maxSeen;
          if (!crossed) return;
          if (getAutoSumAuto()) {
            doGenerateSummary({ silent: true }).then(() =>
              topToast(`Context auto-updated from chat ${SVG_CHECK}`)
            );
          } else {
            topToast(`Context ready to refresh — ${maxSeen + 1} messages in chat`);
          }
        }, 900);
      });
      _sumObserver.observe(container, { childList: true, subtree: true });
    };
    tryStart();
  }

  function _countVirtuosoItems() {
    let max = 0;
    document.querySelectorAll(VIRTUOSO_SEL)
      .forEach(n => {
        const v = parseInt(n.getAttribute('data-index'), 10);
        if (!isNaN(v) && v > max) max = v;
      });
    return max;
  }
  /**
   * Disconnects the auto-summary MutationObserver and cancels the debounce
   * timer.  Safe to call when the observer is already stopped.
   */
  function stopAutoSumObserver() {
    if (_sumObserver) { _sumObserver.disconnect(); _sumObserver = null; }
    clearTimeout(_sumTimer);
  }

  const _loadedMap   = new Map(); 
  const _loadedOrder = [];        
  function _accumMsgs(msgs) {
    let added = 0;
    for (const msg of msgs) {
      const key = _hashStr(msg.text.slice(0, 120));
      if (!_loadedMap.has(key)) {
        _loadedMap.set(key, msg);
        _loadedOrder.push(key);
        added++;
      }
    }
    return added;

}
  function _getAccumulated() {
    return _loadedOrder.map(k => _loadedMap.get(k));

}
  function clearAccumulated() {
    _loadedMap.clear();
    _loadedOrder.length = 0;
  }

  // ─── SPEED DIAL ────────────────────────────────────────────────────────────

  let _dialOpen = false;
  function toggleDial() {
    if (_dialOpen) { closeDial(); return; }
    _dialOpen = true;
    const fab = document.getElementById('ms2-fab');
    if (!fab) return;

    const right  = parseInt(fab.style.right,  10) || CFG.fabRight;
    const bottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;

    const overlay = document.createElement('div');
    overlay.id = 'ms2-dial-overlay';
    overlay.addEventListener('click', () => closeDial());

    const items = [
      { icon: SVG_SCISSORS,  label: 'Shorten',    color: '#8b5cf6', action: () => { closeDial(); handleShorten();   } },
      { icon: SVG_REPLY,     label: 'Smart Reply', color: '#22d3ee', action: () => { closeDial(); handleReply();     } },
      { icon: SVG_STYLES,    label: 'Styles',      color: '#f59e0b', action: () => { closeDial(); handleStyles();    } },
      { icon: SVG_SUMMARISE, label: 'Summarise',   color: '#10b981', action: () => { closeDial(); handleSummarize(); } },
      { icon: SVG_PERSONA,   label: 'Personas',    color: '#f472b6', action: () => handlePersonasQuickSwitch(overlay, right, bottom) },
      { icon: SVG_CHAT,      label: 'Community',   color: '#06b6d4', action: () => { closeDial(); handleCommunityChat(); } },
    ];

    const panel = document.createElement('div');
    panel.className = 'ms2-dial-panel';
    panel.style.cssText = `right:${right + 56}px;bottom:${bottom}px;animation:ms2-dial-in 0.18s cubic-bezier(0.16,1,0.3,1) both;`;
    panel.addEventListener('click', e => e.stopPropagation());

    items.forEach(item => {
      const row = document.createElement('button');
      row.className = 'ms2-dial-row';
      row.innerHTML = `<span class="ms2-dial-row-icon" style="color:${item.color};">${item.icon}</span><span class="ms2-dial-row-label">${item.label}</span>`;
      row.addEventListener('click', () => item.action());
      panel.appendChild(row);
    });

    overlay.appendChild(panel);
    document.body.appendChild(overlay);
    fab.classList.add('ms2-dial-open');
  }

  function closeDial() {
    _dialOpen = false;
    document.getElementById('ms2-dial-overlay')?.remove();
    document.getElementById('ms2-fab')?.classList.remove('ms2-dial-open');
  }

  // ─── ACTION HANDLERS ───────────────────────────────────────────────────────
  function handleShorten() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
    const text = getLatestAIText();
    text ? openShortenModal(text) : openNoTextModal();
  }

  function handleReply() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
    const text = getLatestAIText();
    text ? openReplyModal(text) : openNoTextModal();
  }

  function handleStyles() {
    openSettingsModal('styles');
  }
  function handlePersonasQuickSwitch(overlay, fabRight, fabBottom) {
    
    const existing = document.getElementById('ms2-persona-popup');
    if (existing) { existing.remove(); return; }

    const personas = getPersonaLib();
    const popupW   = 248;
    const gap      = 12;

    const rightEdge = fabRight + 44 + gap;
    const safeRight = Math.min(rightEdge, window.innerWidth - popupW - 8);

    const popup = document.createElement('div');
    popup.id = 'ms2-persona-popup';
    popup.style.cssText =
      `position:fixed;z-index:999999;right:${safeRight}px;bottom:${fabBottom - 8}px;` +
      `width:${popupW}px;max-height:310px;display:flex;flex-direction:column;` +
      `background:#1a1625;border:1px solid rgba(244,114,182,0.45);border-radius:11px;` +
      `box-shadow:0 6px 28px rgba(0,0,0,0.65);overflow:hidden;animation:ms2-up 0.16s ease;`;

    popup.innerHTML = `
      <div style="padding:8px 10px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0;">
        <div style="display:flex;align-items:center;gap:5px;font-size:10px;color:#f9a8d4;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;">${SVG_PERSONA} Persona Quick-Switch</div>
        <input id="ms2-pp-filter" type="text" placeholder="${personas.length > 0 ? 'Search personas…' : 'No personas yet'}"
          style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);
          border-radius:5px;color:#e2e8f0;font-size:12px;padding:5px 8px;outline:none;font-family:system-ui,sans-serif;"
          ${personas.length === 0 ? 'disabled' : ''}>
      </div>
      <div id="ms2-pp-list" style="overflow-y:auto;flex:1;padding:4px;"></div>
      <div style="padding:5px 8px;border-top:1px solid rgba(255,255,255,0.06);flex-shrink:0;text-align:center;">
        <span style="font-size:10px;color:#374151;">Long-press FAB → Context tab to manage</span>
      </div>`;

    popup.addEventListener('click', e => e.stopPropagation());

    overlay.appendChild(popup);

    const filterIn = popup.querySelector('#ms2-pp-filter');
    const listEl   = popup.querySelector('#ms2-pp-list');

    function renderList(q = '') {
      listEl.innerHTML = '';
      if (!personas.length) {
        listEl.innerHTML =
          '<div style="font-size:11px;color:#4b5563;padding:10px 8px;text-align:center;line-height:1.5;">' +
          'No personas saved yet.<br>Add them in <strong style="color:#9ca3af;">Settings → Context</strong>.</div>';
        return;
      }
      const hits = q
        ? personas.filter(p => (p.name + p.desc).toLowerCase().includes(q.toLowerCase()))
        : personas;
      if (!hits.length) {
        listEl.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:8px;text-align:center;">No matches.</div>';
        return;
      }
      
      listEl.innerHTML = hits.map(p =>
        `<div class="ms2-pp-row" data-pid="${escHtml(p.id)}">` +
          `<span style="flex:1;font-size:12px;color:#f9a8d4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${escHtml(p.name)}">${escHtml(p.name)}</span>` +
          `<button class="pp-use" data-pid="${escHtml(p.id)}" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:3px 9px;flex-shrink:0;font-family:system-ui,sans-serif;">Use</button>` +
        `</div>`
      ).join('');
    }

    listEl.addEventListener('click', e => {
      const btn = e.target.closest('.pp-use');
      if (!btn) return;
      e.stopPropagation();
      const pid = btn.dataset.pid;
      const p = personas.find(x => x.id === pid);
      if (!p) return;
      saveContext(p.desc);
      closeDial();
      toastHTML(`${SVG_PERSONA} ${escHtml(p.name)} loaded`);
    });

    let _ppFilterTimer = null;
    filterIn?.addEventListener('input', () => {
      clearTimeout(_ppFilterTimer);
      _ppFilterTimer = setTimeout(() => renderList(filterIn.value), 120);
    });
    filterIn?.addEventListener('keydown', e => { if (e.key === 'Escape') closeDial(); });
    renderList();
    setTimeout(() => filterIn?.focus(), 60);
  }

  function _showFabSumWarning(newMsgCount, timeAgo) {
    document.getElementById('ms2-fabsum-warn-backdrop')?.remove();
    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-fabsum-warn-backdrop';
    setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 300);
    addEscapeClose(backdrop);

    const clampedNew = Math.max(0, newMsgCount);
    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '420px';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_WARNING} Too soon to re-summarise</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-tip" style="border-color:rgba(251,191,36,0.5);color:#fcd34d;margin-bottom:12px;">
          ${SVG_SUMMARISE} Last summary was <strong>${timeAgo}</strong> — only
          <strong>${clampedNew} new message${clampedNew !== 1 ? 's' : ''}</strong> since then.
        </div>
        <p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
          Summaries work best after <strong style="color:#c4b5fd;">${FAB_SUM_MIN_NEW_MSGS}+ new messages</strong>.
          Running one too soon produces a nearly identical result and wastes your API quota.
        </p>
        <p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">
          Keep chatting and come back when more has happened —
          or run it now if you genuinely need a fresh copy.
        </p>
      </div>
      <div class="ms2-modal-footer" style="gap:8px;">
        <button class="ms2-btn-action ms2-btn-copy" id="ms2-fabwarn-close-btn">Not yet — keep chatting</button>
        <button class="ms2-btn-action ms2-btn-retry" id="ms2-fabwarn-force-btn">${SVG_SUMMARISE} Run anyway</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);
    modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
    modal.querySelector('#ms2-fabwarn-close-btn').addEventListener('click', () => backdrop.remove());
    modal.querySelector('#ms2-fabwarn-force-btn').addEventListener('click', () => {
      backdrop.remove();
      doFABSummarize();
    });

}
  function handleSummarize() {
    if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }

    const last = getFabSumLast();
    if (last) {
      const currentIdx = _countVirtuosoItems();
      const delta      = currentIdx - last.domIndex;
      if (delta >= 0 && delta < FAB_SUM_MIN_NEW_MSGS) {
        const mins = Math.round((Date.now() - last.ts) / 60000);
        const timeAgo = mins < 1 ? 'just now' : `${mins} minute${mins !== 1 ? 's' : ''} ago`;
        _showFabSumWarning(delta, timeAgo);
        return;
      }
    }

    doFABSummarize();
  }

  // ─── FAB SUMMARISE — FULL HISTORY → CLIPBOARD ──────────────────────────────

  let _fabSumRunning = false;
  /**
   * Full-history memory summary triggered from the FAB speed-dial.
   *
   * Runs `doLoadAll` (auto-scrolls the entire chat), then calls
   * `buildMemoryBoxPrompt` → `callAPI` → copies result to clipboard
   * and shows a preview modal.  The result intentionally does NOT overwrite
   * Scene Context — it is designed for JanitorAI's Chat Memory panel.
   *
   * Guards against concurrent runs with `_fabSumRunning`.
   *
   * @returns {Promise<void>}
   */
  async function doFABSummarize() {
    if (_fabSumRunning) return;
    _fabSumRunning = true;

    const progressToast = toastHTML(`${SVG_ARROW_UP} Loading full chat history…`, 120000);
    try {
      const result = await doLoadAll((loaded) => {
        if (progressToast.isConnected) progressToast.innerHTML = `${SVG_ARROW_UP} Loading… ${loaded} msgs`;
      });
      progressToast?.remove?.();
      if (result === -1) {
        toastHTML(`${SVG_WARNING} Could not find chat scroll area — are you in an active chat?`, 4000);
        _fabSumRunning = false;
        return;
      }
    } catch (e) {
      progressToast?.remove?.();
      toastHTML(`${SVG_WARNING} Load failed: ${escHtml(e.message)}`, 4000);
      _fabSumRunning = false;
      return;
    }

    _accumMsgs(scrapeChatMessages());
    const msgs = _getAccumulated();
    if (msgs.length < 1) {
      toastHTML(`${SVG_WARNING} No messages found in this chat.`, 3500);
      _fabSumRunning = false;
      return;
    }

    const genToast = toastHTML(`${SVG_SUMMARISE} Writing memory entry from ${msgs.length} messages…`, 120000);
    try {
      const { system, user, charLimit } = buildMemoryBoxPrompt(msgs);
      const raw = await callAPI(system, user, { temperature: 0.3, max_tokens: 700 });
      const summary = raw.trim().slice(0, charLimit);
      genToast?.remove?.();

      navigator.clipboard.writeText(summary).catch(() => {});

      setFabSumLast(_countVirtuosoItems());

      const backdrop = document.createElement('div');
      backdrop.className = 'ms2-backdrop';
      backdrop.id = 'ms2-fabsum-backdrop';
      setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 350);
      addEscapeClose(backdrop);
      const modal = document.createElement('div');
      modal.className = 'ms2-modal';
      modal.setAttribute('role', 'dialog');
      modal.setAttribute('aria-modal', 'true');
      modal.innerHTML = `
        <div class="ms2-modal-header">
          <div class="ms2-modal-title" style="display:flex;align-items:center;gap:6px;">
            ${SVG_SUMMARISE} Memory Summary
            <button id="ms2-fabsum-help-btn" title="How is this different from Context Generate?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:18px;height:18px;color:#9ca3af;font-size:10px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
          </div>
          <button class="ms2-modal-close">×</button>
        </div>
        <div class="ms2-modal-body">
          <div class="ms2-tip" style="margin-bottom:10px;border-color:rgba(16,185,129,0.4);color:#6ee7b7;">
            ${SVG_CHECK} <strong>Copied to clipboard!</strong> Built from <strong>${msgs.length} messages</strong> (full history).<br>
            You can paste this anywhere you store long-term memory — e.g. JanitorAI's Chat Memory panel (≡ → Chat Memory).
          </div>
          <div class="ms2-label">Memory Entry</div>
          <div class="ms2-textbox result" style="white-space:pre-wrap;font-size:12px;">${escHtml(summary)}</div>
          <div class="ms2-tip" style="margin-top:8px;font-size:11px;">
            ${SVG_TIP} <strong>Not saved to Scene Context — by design.</strong><br>
            • <strong>Summarise</strong> = full story arc, who the characters are, relationship backstory<br>
            • <strong>Context Generate</strong> = current-situation note injected into every reply
          </div>
        </div>
        <div class="ms2-modal-footer">
          <button class="ms2-btn-action ms2-btn-copy" id="ms2-fabsum-copy-btn">${SVG_COPY} Copy again</button>
          <button class="ms2-btn-action ms2-btn-retry" id="ms2-fabsum-close-btn">Close</button>
        </div>`;
      backdrop.appendChild(modal);
      document.body.appendChild(backdrop);
      modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
      modal.querySelector('#ms2-fabsum-close-btn').addEventListener('click', () => backdrop.remove());
      modal.querySelector('#ms2-fabsum-copy-btn').addEventListener('click', () => {
        navigator.clipboard.writeText(summary).then(() => {
          const b = modal.querySelector('#ms2-fabsum-copy-btn');
          b.innerHTML = `${SVG_CHECK} Copied!`;
          setTimeout(() => { if (b.isConnected) b.innerHTML = `${SVG_COPY} Copy again`; }, 1800);
        });
      });

      modal.querySelector('#ms2-fabsum-help-btn')?.addEventListener('click', () => {
        document.getElementById('fabsum-help-backdrop')?.remove();
        const hB = document.createElement('div');
        hB.className = 'ms2-backdrop'; hB.id = 'fabsum-help-backdrop'; hB.style.zIndex = '10000020';
        const hM = document.createElement('div');
        hM.className = 'ms2-modal'; hM.style.maxWidth = '480px';
        hM.innerHTML = `
          <div class="ms2-modal-header">
            <div class="ms2-modal-title">${SVG_SUMMARISE} Summarise vs Context Generate</div>
            <button class="ms2-modal-close" id="fsh-close">×</button>
          </div>
          <div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">
            <p style="margin:0 0 10px;"><strong style="color:#10b981;">FAB → Summarise (this button)</strong><br>
            Reads your <strong>entire chat history</strong> automatically (runs Load All for you). Produces a persistent summary: who the characters are, their relationship arc, and the major story events. Output goes to <strong>clipboard</strong> — paste it wherever you store long-term context (e.g. JanitorAI's Chat Memory panel).</p>
            <p style="margin:0 0 10px;"><strong style="color:#a78bfa;">Context tab → Generate</strong><br>
            Reads only <strong>currently visible messages</strong>. Produces a current-situation note: where you are, what just happened. Saves to <strong>Scene Context</strong> and gets injected into every JanitorAI reply (when "Send to JanitorAI's AI" is ON).</p>
            <table style="width:100%;border-collapse:collapse;font-size:12px;margin:0 0 10px;">
              <tr style="color:#6b7280;border-bottom:1px solid rgba(255,255,255,0.07);">
                <th style="text-align:left;padding:4px 8px 4px 0;"></th>
                <th style="text-align:left;padding:4px 8px;color:#10b981;">Summarise</th>
                <th style="text-align:left;padding:4px 8px;color:#a78bfa;">Context Generate</th>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Source</td>
                <td style="padding:5px 8px;">Full history (auto Load All)</td>
                <td style="padding:5px 8px;">Visible messages only</td>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Output</td>
                <td style="padding:5px 8px;">Clipboard only</td>
                <td style="padding:5px 8px;">Scene Context field</td>
              </tr>
              <tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Paste into</td>
                <td style="padding:5px 8px;">Clipboard (paste anywhere)</td>
                <td style="padding:5px 8px;">Stays in Scene Context</td>
              </tr>
              <tr>
                <td style="padding:5px 8px 5px 0;color:#9ca3af;">Purpose</td>
                <td style="padding:5px 8px;">Who they are, backstory</td>
                <td style="padding:5px 8px;">What is happening now</td>
              </tr>
            </table>
            <p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Tip: Use both tools together — Summarise captures the full story arc; Context Generate keeps the AI oriented to the current scene.</p>
          </div>`;
        hB.appendChild(hM);
        document.body.appendChild(hB);
        const hClose = () => hB.remove();
        hM.querySelector('#fsh-close').addEventListener('click', hClose);
        setTimeout(() => hB.addEventListener('click', e => { if (e.target === hB) hClose(); }), 300);
        addEscapeClose(hB);
      });

    } catch (err) {
      genToast?.remove?.();
      if (err.name !== 'AbortError') toastHTML(`${SVG_WARNING} Memory summary failed: ${escHtml(err.message)}`, 4000);
    } finally {
      _fabSumRunning = false;
    }
  }

  // ─── NO TEXT MODAL ─────────────────────────────────────────────────────────
  function openNoTextModal() {
    document.getElementById('ms2-main-backdrop')?.remove();
    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-main-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);
    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '340px';
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SCISSORS} No message found</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-no-text">
          <strong>Could not find an AI message.</strong><br><br>
          Make sure you're in an active chat with at least one character response visible on screen.
        </div>
      </div>`;
    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);
    modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
  }

  // ─── SHORTEN MODAL ─────────────────────────────────────────────────────────
  function openShortenModal(originalText) {
    document.getElementById('ms2-main-backdrop')?.remove();

    let selectedLength = CFG.shortenLength;
    let keepDialogue   = CFG.keepDialogue;

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-main-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');

    const lengthBtns = ['brief', 'compact', 'trim'].map(l =>
      `<button class="ms2-length-btn ${selectedLength === l ? 'active' : ''}" data-len="${l}">${l === 'brief' ? 'Slash (~30%)' : l === 'compact' ? 'Halve (~50%)' : 'Polish (~70%)'}</button>`
    ).join('');

    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SCISSORS} Make Shorter</div>
        <button class="ms2-modal-close" title="Close">×</button>
      </div>
      <div class="ms2-modal-body">
        <div class="ms2-label">Length</div>
        <div class="ms2-length-row">${lengthBtns}</div>
        <div class="ms2-toggle-row">
          <span class="ms2-toggle-label">Keep all dialogue (never cut spoken lines)</span>
          <label class="ms2-toggle-switch">
            <input type="checkbox" id="ms2-keep-dlg" ${keepDialogue ? 'checked' : ''}>
            <span class="ms2-toggle-thumb"></span>
          </label>
        </div>
        <div class="ms2-tip" style="margin:10px 0 8px;font-size:11px;padding:7px 10px;">Output quality depends on your model — free or smaller models may miss the target length or shift the tone slightly. Try a retry if the first result feels off.</div>
        <div class="ms2-label" style="margin-top:4px;">Original</div>
        <div class="ms2-textbox">${escHtml(originalText)}</div>
        <div class="ms2-label" id="ms2-shorten-label" style="display:none;">Shortened</div>
        <div id="ms2-shorten-area"></div>
      </div>
      <div class="ms2-modal-footer" id="ms2-shorten-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ms2-shorten-gen-btn">${SVG_SCISSORS} Shorten</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const resultArea  = modal.querySelector('#ms2-shorten-area');
    const resultLabel = modal.querySelector('#ms2-shorten-label');
    const footer      = modal.querySelector('#ms2-shorten-footer');
    const genBtn      = modal.querySelector('#ms2-shorten-gen-btn');
    let resultText = '';
    let _shortenAbort = null;

    modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
      _shortenAbort?.abort();
      backdrop.remove();
    });

    modal.querySelectorAll('.ms2-length-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        selectedLength = btn.dataset.len;
        CFG.shortenLength = selectedLength;
        modal.querySelectorAll('.ms2-length-btn').forEach(b => b.classList.toggle('active', b === btn));
      });
    });

    modal.querySelector('#ms2-keep-dlg').addEventListener('change', e => {
      keepDialogue = e.target.checked;
      CFG.keepDialogue = keepDialogue;
    });

    const run = async () => {
      _shortenAbort?.abort();
      _shortenAbort = new AbortController();
      resultArea.innerHTML = '<div class="ms2-spinner">Generating…</div>';
      resultLabel.style.display = '';
      resultLabel.textContent = 'Shortened';
      genBtn.disabled = true;
      genBtn.style.display = 'none';
      
      footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-retry').forEach(b => b.remove());
      try {
        const prompt = buildShortenPrompt(selectedLength, keepDialogue);
        resultText = await callAPI(prompt, originalText, { temperature: 0.65, max_tokens: 1500, signal: _shortenAbort.signal });
        
        const origWords   = originalText.trim().split(/\s+/).length;
        const resultWords = resultText.trim().split(/\s+/).length;
        const pct = Math.max(0, Math.round((1 - resultWords / origWords) * 100));
        resultLabel.innerHTML = `Shortened <span class="ms2-badge">↓${pct}% words</span>`;
        resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;

        const copyBtn = document.createElement('button');
        copyBtn.className = 'ms2-btn-action ms2-btn-copy';
        copyBtn.innerHTML = `${SVG_COPY} Copy`;
        copyBtn.addEventListener('click', () => {
          navigator.clipboard.writeText(resultText)
            .then(() => {
              copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
              
              setTimeout(() => { if (copyBtn.isConnected) copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
            })
            .catch(() => toast('Clipboard unavailable — please copy manually.'));
        });

        const replaceBtn = document.createElement('button');
        replaceBtn.className = 'ms2-btn-action ms2-btn-send';
        replaceBtn.textContent = '✎ Replace';
        replaceBtn.title = 'Try to replace the AI message in chat directly';
        replaceBtn.addEventListener('click', () => {
          backdrop.remove();
          replaceLatestAIMessage(
            resultText,
            () => toastHTML(`${SVG_CHECK} Message replaced in chat`),
            () => {
              navigator.clipboard.writeText(resultText)
                .then(() => toast('Could not edit message automatically — copied to clipboard instead.', 3500))
                .catch(() => toast('Could not edit message — copy manually.', 4000));
            }
          );
        });

        const retryBtn = document.createElement('button');
        retryBtn.className = 'ms2-btn-action ms2-btn-retry';
        retryBtn.textContent = '↺ Retry';
        retryBtn.addEventListener('click', run);

        footer.appendChild(copyBtn);
        footer.appendChild(replaceBtn);
        footer.appendChild(retryBtn);
        genBtn.style.display = '';
        genBtn.disabled = false;
      } catch (err) {
        if (err.name === 'AbortError') return;
        resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
        genBtn.style.display = '';
        genBtn.disabled = false;
      }
    };

    genBtn.addEventListener('click', run);
  }

  // ─── REPLY MODAL ───────────────────────────────────────────────────────────
  function openReplyModal(latestMsg) {
    document.getElementById('ms2-reply-backdrop')?.remove();

    const presets       = getPresets();
    const activePreset  = presets.find(p => p.id === CFG.activePreset) || null;
    let selectedTone    = activePreset?.tone || CFG.defaultTone || '';

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-reply-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const toneGrid = TONES.map(t =>
      `<button class="ms2-tone-btn ${selectedTone === t.id ? 'active' : ''}" data-tone="${t.id}">${t.label}</button>`
    ).join('');

    const presetChip = activePreset
      ? `<div class="ms2-preset-chip">${SVG_STYLES} ${escHtml(activePreset.name)} active</div>`
      : '';

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.setAttribute('role', 'dialog');
    modal.setAttribute('aria-modal', 'true');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_REPLY} Smart Reply</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-modal-body">
        ${presetChip}
        <div class="ms2-label">AI Said</div>
        <div class="ms2-textbox ms2-textbox-preview">${escHtml(latestMsg)}</div>
        <div class="ms2-label">Tone</div>
        <div class="ms2-tone-grid" style="margin-bottom:12px;">${toneGrid}</div>
        <div class="ms2-label">Custom Instruction <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;">(optional)</span></div>
        <textarea class="ms2-instruction-box" id="ms2-reply-instruct" placeholder="e.g. Push back but secretly enjoy it…">${escHtml(CFG.defaultInstruct)}</textarea>
        <div class="ms2-label" id="ms2-reply-result-label" style="display:none;">Generated Reply</div>
        <div id="ms2-reply-result-area"></div>
      </div>
      <div class="ms2-modal-footer" id="ms2-reply-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ms2-gen-btn">${SVG_CONFIG} Generate Reply</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const resultArea  = modal.querySelector('#ms2-reply-result-area');
    const resultLabel = modal.querySelector('#ms2-reply-result-label');
    const footer      = modal.querySelector('#ms2-reply-footer');
    const genBtn      = modal.querySelector('#ms2-gen-btn');
    let resultText = '';
    let _replyAbort = null;

    modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
      _replyAbort?.abort();
      backdrop.remove();
    });

    modal.querySelectorAll('.ms2-tone-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        if (selectedTone === btn.dataset.tone) {
          selectedTone = '';
          btn.classList.remove('active');
        } else {
          selectedTone = btn.dataset.tone;
          modal.querySelectorAll('.ms2-tone-btn').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
        }
      });
    });

    let everSucceeded = false;

    const run = async () => {
      _replyAbort?.abort();
      _replyAbort = new AbortController();
      const customInstruct = modal.querySelector('#ms2-reply-instruct').value.trim();
      resultArea.innerHTML = '<div class="ms2-spinner">Generating reply…</div>';
      resultLabel.style.display = '';

      genBtn.disabled = true;
      genBtn.style.display = 'none';
      footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-send,.ms2-btn-retry').forEach(b => b.remove());

      try {
        const prompt = buildReplyPrompt(selectedTone, customInstruct, activePreset);
        resultText = await callAPI(prompt, `The character just said:\n\n${latestMsg}`, { temperature: 0.9, max_tokens: 1200, signal: _replyAbort.signal });
        resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;
        everSucceeded = true;

        const copyBtn = document.createElement('button');
        copyBtn.className = 'ms2-btn-action ms2-btn-copy';
        copyBtn.innerHTML = `${SVG_COPY} Copy`;
        copyBtn.addEventListener('click', () => {
          navigator.clipboard.writeText(resultText).then(() => {
            copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
            setTimeout(() => { copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
          });
        });

        const sendBtn = document.createElement('button');
        sendBtn.className = 'ms2-btn-action ms2-btn-send';
        sendBtn.innerHTML = `${SVG_KEYBOARD} Send`;
        sendBtn.addEventListener('click', () => {
          backdrop.remove();
          injectAndSend(
            resultText,
            () => toastHTML(`${SVG_CHECK} Reply sent!`),
            () => {
              navigator.clipboard.writeText(resultText)
                .then(() => toast('Could not find chat input — copied to clipboard instead.', 3500))
                .catch(() => toast('Could not find chat input — clipboard unavailable. Copy manually.', 4000));
            }
          );
        });

        const retryBtn = document.createElement('button');
        retryBtn.className = 'ms2-btn-action ms2-btn-retry';
        retryBtn.innerHTML = `${SVG_REROLL} Reroll`;
        retryBtn.title = 'Generate a different version with the same tone & instructions';
        retryBtn.addEventListener('click', run);

        footer.appendChild(copyBtn);
        footer.appendChild(sendBtn);
        footer.appendChild(retryBtn);

      } catch (err) {
        if (err.name === 'AbortError') return;
        resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
        if (everSucceeded) {
          
          const retryBtn = document.createElement('button');
          retryBtn.className = 'ms2-btn-action ms2-btn-retry';
          retryBtn.textContent = '↺ Retry';
          retryBtn.addEventListener('click', run);
          footer.appendChild(retryBtn);
        } else {
          
          genBtn.style.display = '';
          genBtn.disabled = false;
        }
      }
    };

    genBtn.addEventListener('click', run);

  } // end openReplyModal

  // ─── SETTINGS MODAL (5 tabs) ───────────────────────────────────────────────

  /** Builds the HTML for the General settings tab. */
  function _buildGeneralTab(tab0, modelOpts) {
    const _pinActive  = _pinIsActive();
    const _pinUnlocked = _pinSession.active;
    const _pinStatusHTML = _pinActive
      ? (_pinUnlocked
          ? `<div class="jv5-pin-status unlocked">${SVG_SHIELD} PIN active — key encrypted &amp; unlocked for this session</div>`
          : `<div class="jv5-pin-status locked">${SVG_LOCK} PIN active — key is locked. Enter PIN to unlock.</div>`)
      : `<div class="jv5-pin-status off">${SVG_WARNING} No PIN set — key stored as plain text</div>`;

    return `
        <div class="ms2-tab-panel ${tab0 === 'general' ? 'active' : ''}" data-panel="general">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_SETTINGS} GENERAL SETTINGS</span>
          </div>

          <!-- API Provider -->
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">API Provider</label>
            ${_makeInfoBtn('api-provider')}
          </div>
          <select class="ms2-select" id="ms2-s-ep-sel">
            <option value="https://openrouter.ai/api/v1"    ${CFG.endpoint === 'https://openrouter.ai/api/v1'    ? 'selected' : ''}>OpenRouter (free &amp; paid — easiest start)</option>
            <option value="https://api.openai.com/v1"       ${CFG.endpoint === 'https://api.openai.com/v1'       ? 'selected' : ''}>OpenAI (GPT-4o, o4-mini…)</option>
            <option value="https://api.x.ai/v1"             ${CFG.endpoint === 'https://api.x.ai/v1'             ? 'selected' : ''}>xAI (Grok 3 / Grok 4)</option>
            <option value="https://api.anthropic.com"       ${CFG.endpoint === 'https://api.anthropic.com'       ? 'selected' : ''}>Anthropic (Claude — native API)</option>
            <option value="https://api.mistral.ai/v1"       ${CFG.endpoint === 'https://api.mistral.ai/v1'       ? 'selected' : ''}>Mistral AI (Magistral, Devstral…)</option>
            <option value="https://api.groq.com/openai/v1"  ${CFG.endpoint === 'https://api.groq.com/openai/v1'  ? 'selected' : ''}>Groq (Llama 4, Qwen 3 — very fast)</option>
            <option value="https://lorebary.com/csproxy"    ${CFG.endpoint?.startsWith('https://lorebary.com/csproxy') ? 'selected' : ''}>LiteRouter / Lorebary (custom proxy)</option>
            <option value="custom"                          ${!KNOWN_EPS.includes(CFG.endpoint)                  ? 'selected' : ''}>Custom / other proxy URL…</option>
          </select>
          <div id="ms2-s-custom-ep-wrap" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}">
            <label class="ms2-field-label">Custom Base URL</label>
            <input type="text" class="ms2-input" id="ms2-s-custom-ep" value="${escHtml(!KNOWN_EPS.includes(CFG.endpoint) ? CFG.endpoint : '')}" placeholder="https://your-proxy.example.com/v1">
          </div>

          <!-- API Key with PIN security -->
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:2px;">
            <label class="ms2-field-label" style="margin-bottom:0;">API Key</label>
            ${_makeInfoBtn('api-key-security')}
          </div>
          ${_pinStatusHTML}
          ${_pinActive ? `
          <div style="display:flex;align-items:center;gap:6px;margin:4px 0 6px;">
            <label class="ms2-field-label" style="margin-bottom:0;font-size:10.5px;color:#9ca3af;">Auto-lock after inactivity</label>
            <select class="ms2-select" id="ms2-pin-idle-sel" style="width:auto;padding:3px 8px;font-size:11px;">
              <option value="0"  ${_pinGetIdleMin() === 0  ? 'selected' : ''}>Off</option>
              <option value="5"  ${_pinGetIdleMin() === 5  ? 'selected' : ''}>5 min</option>
              <option value="15" ${_pinGetIdleMin() === 15 ? 'selected' : ''}>15 min</option>
              <option value="30" ${_pinGetIdleMin() === 30 ? 'selected' : ''}>30 min</option>
              <option value="60" ${_pinGetIdleMin() === 60 ? 'selected' : ''}>60 min</option>
            </select>
            ${_makeInfoBtn('pin-idle-lock')}
          </div>` : ''}
          <div style="display:flex;gap:6px;align-items:center;">
            <input type="text" class="ms2-input" id="ms2-s-apikey"
              value="${_pinActive && !_pinUnlocked ? '' : escHtml(CFG.apiKey)}"
              placeholder="${_pinActive && !_pinUnlocked ? 'Locked — click Unlock PIN to reveal' : 'Paste your API key…'}"
              ${_pinActive && !_pinUnlocked ? 'readonly' : ''}
              autocomplete="off" autocorrect="off" autocapitalize="off"
              spellcheck="false" data-lpignore="true" data-form-type="other" data-1p-ignore="true"
              style="margin-bottom:0;flex:1;min-width:0;-webkit-text-security:disc;">
            <button class="ap-icon-btn" id="ms2-test-api-btn" title="Test connection" style="flex-shrink:0;">${SVG_CONFIG}</button>
          </div>

          <!-- PIN action row -->
          <div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
            ${!_pinActive ? `
              <button id="ms2-pin-set-btn" style="
                flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
                background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.45);
                color:#c4b5fd;font-family:system-ui,sans-serif;">
                ${SVG_LOCK} Set PIN Encryption
              </button>` : ''}
            ${_pinActive && !_pinUnlocked ? `
              <button id="ms2-pin-unlock-btn" style="
                flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
                background:rgba(16,185,129,0.12);border:1px solid rgba(16,185,129,0.4);
                color:#6ee7b7;font-family:system-ui,sans-serif;">
                ${SVG_UNLOCK} Unlock PIN
              </button>` : ''}
            ${_pinActive ? `
              <button id="ms2-pin-change-btn" style="
                flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
                background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.35);
                color:#818cf8;font-family:system-ui,sans-serif;">
                ✎ Change PIN
              </button>
              <button id="ms2-pin-remove-btn" style="
                flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
                background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);
                color:#f87171;font-family:system-ui,sans-serif;">
                ${SVG_UNLOCK} Remove PIN
              </button>` : ''}
          </div>
          <div id="ms2-api-test-result" style="font-size:11px;margin-top:6px;min-height:16px;"></div>

          <!-- Auth Header -->
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:8px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Auth Header Format</label>
            ${_makeInfoBtn('auth-mode')}
          </div>
          <select class="ms2-select" id="ms2-s-auth-mode" style="margin-bottom:2px;">
            <option value="auto"    ${CFG.authMode === 'auto'    ? 'selected' : ''}>Auto-detect (recommended)</option>
            <option value="bearer"  ${CFG.authMode === 'bearer'  ? 'selected' : ''}>Bearer &lt;key&gt; — standard OpenAI / OpenRouter</option>
            <option value="raw"     ${CFG.authMode === 'raw'     ? 'selected' : ''}>Key only — LiteRouter &amp; niche proxies</option>
            <option value="x-api-key" ${CFG.authMode === 'x-api-key' ? 'selected' : ''}>x-api-key header — Anthropic-style proxies</option>
          </select>
          <div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
            Auto-detect uses <code>Bearer</code> for all keys — works with OpenRouter, OpenAI, LiteRouter, and most proxies. Only change this if your proxy specifically rejects <code>Bearer</code>.
          </div>

          <!-- Model -->
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Model</label>
            ${_makeInfoBtn('model-select')}
          </div>
          <select class="ms2-select" id="ms2-s-model">
            ${modelOpts}
            <option value="__custom__" ${!MODELS.find(m => m.id === CFG.model) ? 'selected' : ''}>Custom model ID…</option>
          </select>
          <input type="text" class="ms2-input" id="ms2-s-custom-model" placeholder="e.g. openai/gpt-4o-mini" value="${escHtml(!MODELS.find(m => m.id === CFG.model) ? CFG.model : '')}" style="${MODELS.find(m => m.id === CFG.model) ? 'display:none;margin-top:-6px' : 'margin-top:-6px'}">
          <div id="ms2-proxy-model-warn" class="ms2-tip" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}; border-color:rgba(251,191,36,0.4); color:#fbbf24;">
            ${SVG_WARNING} <strong>Custom endpoint — model ID format may differ.</strong>
            Proxies like LiteRouter use the same <code style="color:#fbbf24">provider/model:tier</code> format as OpenRouter (preset list works as-is).
            Others (e.g. self-hosted Ollama, meganova.ai) use shorter IDs with no provider prefix — in that case choose <strong>Custom model ID…</strong> below.
            Your API key can be any format your provider issues.
          </div>
          <div class="ms2-tip">
            ${SVG_INFO}
            <strong>Which provider should I pick?</strong><br>
            • <strong>OpenRouter</strong> — widest model selection, many free models (ending in <code style="color:#a78bfa">:free</code>), one key for everything. Get a free key at <a href="https://openrouter.ai/keys" target="_blank">openrouter.ai/keys</a>.<br>
            • <strong>xAI</strong> — Grok 3 / Grok 4, OpenAI-compatible. Key from <a href="https://console.x.ai" target="_blank">console.x.ai</a>.<br>
            • <strong>Anthropic</strong> — Claude Opus / Sonnet / Haiku native. Key from <a href="https://console.anthropic.com" target="_blank">console.anthropic.com</a>. <em>Note: uses its own message format — handled automatically.</em><br>
            • <strong>Mistral</strong> — Magistral reasoning, Devstral (code), Mistral Large. Key from <a href="https://console.mistral.ai" target="_blank">console.mistral.ai</a>.<br>
            • <strong>Groq</strong> — Llama 4, Qwen 3, ultra-fast inference, generous free tier. Key from <a href="https://console.groq.com" target="_blank">console.groq.com</a>.
          </div>
          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-general">Save</button>
            <button class="ms2-btn-cancel" id="ms2-cancel-settings">Cancel</button>
          </div>

          <!-- COMMUNITY CHAT RELAY -->
          <div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
            <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
              <label class="ms2-field-label" style="color:#67e8f9;margin-bottom:0;">${SVG_CHAT} Community Chat Relay URL</label>
              ${_makeInfoBtn('relay-url')}
            </div>
            <input type="text" class="ms2-input" id="ms2-s-relay-url"
              value="${escHtml(_p2pGetRelay())}"
              placeholder="https://ntfy.sh"
              autocomplete="off" spellcheck="false">
            <div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
              Leave blank for the default (<code>https://ntfy.sh</code>). Only change this if you run your own ntfy instance. Private/local addresses are blocked.
            </div>
            <label class="ms2-field-label" style="margin-top:6px;">Relay Access Token <span style="color:#6b7280;font-weight:400;">(self-hosted only, optional)</span></label>
            <input type="text" class="ms2-input" id="ms2-s-relay-token"
              value="${escHtml(_p2pGetRelayToken())}"
              placeholder="ntfy access token (e.g. tk_...)"
              autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
              data-lpignore="true" data-1p-ignore="true" style="-webkit-text-security:disc;">
            <div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
              Sent as <code>Authorization: Bearer …</code> only to the custom relay URL above — never sent to the default ntfy.sh. Use this if your self-hosted ntfy server requires an access token to read/write its topics.
            </div>
            <button class="ms2-btn-save" id="ms2-save-relay" style="padding:5px 14px;font-size:11px;">Save Relay URL</button>
          </div>

          <!-- ADVANCED CRYPTO -->
          <div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
            <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
              <label class="ms2-field-label" style="color:#a78bfa;margin-bottom:0;">🔐 Advanced Crypto</label>
              ${_makeInfoBtn('advanced-crypto')}
            </div>
            <div style="font-size:10.5px;color:#6b7280;margin-bottom:10px;line-height:1.5;">
              Encryption upgrades for Community Chat. Disable only if you have compatibility issues.
            </div>

            <!-- Custom Community Passphrase -->
            <div style="background:rgba(109,40,217,0.07);border:1px solid rgba(109,40,217,0.25);border-radius:8px;padding:11px 12px;margin-bottom:12px;">
              <div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
                <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.4px;">🔑 Custom Community Passphrase</div>
                ${_makeInfoBtn('community-passphrase')}
              </div>
              <div style="font-size:10.5px;color:#6b7280;line-height:1.55;margin-bottom:8px;">
                Creates a private sub-group — only users with the <strong style="color:#e5e7eb;">exact same passphrase</strong> can read your messages.
                <br><span style="color:#f87171;font-weight:600;">⚠️ Clearing this field reverts to the shared default — others with the passphrase won't see you.</span>
              </div>
              <div style="position:relative;display:flex;gap:6px;align-items:center;">
                <input type="password" id="ms2-s-custom-psk"
                  class="ms2-input" style="flex:1;font-family:monospace;font-size:12px;padding-right:36px;"
                  maxlength="128"
                  placeholder="Leave blank for default (shared with all JV5 users)"
                  value="${gget('jv5_custom_psk_set', false) ? '••••••••' : ''}">
                <button id="ms2-psk-toggle" title="Show / hide" style="
                  position:absolute;right:8px;background:none;border:none;
                  color:#6b7280;cursor:pointer;padding:2px;font-size:14px;line-height:1;">👁</button>
              </div>
              <div style="margin-top:6px;height:3px;border-radius:3px;background:rgba(255,255,255,0.07);overflow:hidden;">
                <div id="ms2-psk-strength-bar" style="height:100%;width:0%;background:#ef4444;transition:width .25s,background .25s;border-radius:3px;"></div>
              </div>
              <div id="ms2-psk-strength-label" style="font-size:10px;color:#6b7280;margin-top:3px;min-height:12px;"></div>
              <div style="display:flex;gap:6px;margin-top:8px;">
                <button class="ms2-btn-save" id="ms2-save-custom-psk" style="padding:5px 14px;font-size:11px;">Set Passphrase</button>
                <button id="ms2-clear-custom-psk" style="
                  padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(239,68,68,0.35);
                  background:rgba(239,68,68,0.08);color:#f87171;cursor:pointer;">Clear (use default)</button>
              </div>
            </div>

            <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
              <label class="ms2-toggle-switch" title="Double Ratchet forward secrecy">
                <input type="checkbox" id="ms2-s-ratchet" ${gget('jv5_ratchet_global', true) ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <div style="flex:1;">
                <span style="font-size:12px;color:#c4b5fd;font-weight:500;">Double Ratchet FS</span>
                <div style="font-size:10.5px;color:#6b7280;">Per-peer ECDH ratchet chain. Old message keys deleted after use — past messages stay safe even if PSK is later exposed.</div>
              </div>
            </div>
            <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
              <label class="ms2-toggle-switch" title="PBKDF2-SHA-512 key derivation (stronger than default SHA-256 path)">
                <input type="checkbox" id="ms2-s-argon2" ${gget('jv5_use_argon2', true) ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <div style="flex:1;">
                <span style="font-size:12px;color:#c4b5fd;font-weight:500;">PBKDF2-SHA-512 KDF</span>
                <div style="font-size:10.5px;color:#6b7280;">PBKDF2-SHA-512 at 350,000 iterations — ~2.5× harder to brute-force than standard. Best available via <code>crypto.subtle</code>.</div>
              </div>
            </div>
            <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
              <label class="ms2-toggle-switch" title="HMAC-SHA-512 admin signature verification">
                <input type="checkbox" id="ms2-s-ed25519" ${gget('jv5_use_ed25519_admin', true) ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <div style="flex:1;">
                <span style="font-size:12px;color:#c4b5fd;font-weight:500;">HMAC-SHA-512 Admin Signatures</span>
                <div style="font-size:10.5px;color:#6b7280;">HMAC-SHA-512 + HKDF for admin commands. Doubles MAC length and prevents captured sigs from being reused in other contexts.</div>
              </div>
            </div>
            <button class="ms2-btn-save" id="ms2-save-crypto" style="padding:5px 14px;font-size:11px;">Save Crypto Settings</button>

            <!-- Chat Identity Backup -->
            <div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
              <div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
                <div style="font-size:11px;font-weight:700;color:#fbbf24;letter-spacing:.4px;">💾 Chat Identity Backup</div>
              </div>
              <div style="font-size:10.5px;color:#6b7280;line-height:1.55;margin-bottom:8px;">
                Your chat identity key (<code>jv5_psk_b</code>) is randomly generated on first install and stored in Tampermonkey.
                <strong style="color:#e5e7eb;">If you reinstall Tampermonkey or clear its storage, a new key is generated</strong> — your old encrypted history becomes unreadable and other users cannot decrypt your messages.
                <br><br>
                Back up this key now, then restore it after any reinstall to keep continuity.
              </div>
              <div style="display:flex;gap:6px;margin-bottom:8px;">
                <button id="ms2-psk-backup-btn" style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(251,191,36,0.4);background:rgba(251,191,36,0.08);color:#fbbf24;cursor:pointer;">⬇ Export Key</button>
                <button id="ms2-psk-copy-btn"   style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(99,102,241,0.4);background:rgba(99,102,241,0.08);color:#a78bfa;cursor:pointer;">📋 Copy to Clipboard</button>
              </div>
              <div style="font-size:10.5px;color:#6b7280;margin-bottom:4px;">Restore from backup (paste key value below):</div>
              <div style="display:flex;gap:6px;">
                <input type="text" id="ms2-psk-restore-input"
                  class="ms2-input" style="flex:1;font-family:monospace;font-size:11px;"
                  placeholder="Paste exported key here…"
                  autocomplete="off" spellcheck="false">
                <button id="ms2-psk-restore-btn" style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(16,185,129,0.4);background:rgba(16,185,129,0.08);color:#6ee7b7;cursor:pointer;white-space:nowrap;">⬆ Restore</button>
              </div>
              <div id="ms2-psk-fingerprint" style="font-size:10px;color:#6b7280;margin-top:5px;font-family:monospace;"></div>
            </div>
          </div>
        </div>`;
  }
  /** Builds the HTML for the Reply settings tab. */
  function _buildReplyTab(tab0, toneOpts) {
    return `
        <div class="ms2-tab-panel ${tab0 === 'reply' ? 'active' : ''}" data-panel="reply">
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_REPLY} REPLY SETTINGS</span>
          </div>

          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Default Tone</label>
          </div>
          <select class="ms2-select" id="ms2-s-default-tone">${toneOpts}</select>

          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Default Custom Instruction</label>
          </div>
          <textarea class="ms2-input ms2-textarea-sm" id="ms2-s-default-instruct" placeholder="e.g. Be slightly more reserved than usual">${escHtml(CFG.defaultInstruct)}</textarea>

          <div class="ms2-toggle-row" style="margin-bottom:6px;">
            <div style="display:flex;align-items:center;gap:6px;">
              <span class="ms2-toggle-label">Notify on new AI message</span>
              ${_makeInfoBtn('auto-notify')}
            </div>
            <label class="ms2-toggle-switch">
              <input type="checkbox" id="ms2-s-autonotify" ${CFG.autoNotify ? 'checked' : ''}>
              <span class="ms2-toggle-thumb"></span>
            </label>
          </div>

          <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
            <div class="ms2-tip" style="flex:1;margin:0;">When ON: a subtle toast appears when a new AI message arrives — no auto-opening modals.</div>
            ${_makeInfoBtn('smart-reply')}
          </div>

          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-reply">Save</button>
            <button class="ms2-btn-cancel">Cancel</button>
          </div>
        </div>`
  }

  /** Builds the HTML for the Styles tab. */
  function _buildStylesTab(tab0) {
    return `
        <div class="ms2-tab-panel ${tab0 === 'styles' ? 'active' : ''}" data-panel="styles">
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_STYLES} STYLES</span>
            ${_makeInfoBtn('styles-presets')}
          </div>
          <div class="ms2-tip">Presets save your character's full voice config — tone, custom instruction, persona, and Prompt Modules. The active preset is injected automatically when you open Smart Reply.</div>
          <div id="ms2-presets-list"></div>
          <button class="ms2-btn-new-preset" id="ms2-new-preset-btn">+ New Preset</button>
          <div id="ms2-preset-editor-wrap" style="display:none;"></div>
        </div>`;
  }

  /** Builds the HTML for the Context tab. */
  function _buildContextTab(tab0) {
    return `
        <div class="ms2-tab-panel ${tab0 === 'context' ? 'active' : ''}" data-panel="context">
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
            <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_CONTEXT} CONTEXT</span>
            ${_makeInfoBtn('scene-context')}
          </div>
          <div class="ms2-tip">These notes are injected into every <strong>Reply</strong> prompt and into <strong>Shorten</strong> as editorial context — keyed to <strong>this specific chat URL</strong>. Each conversation has its own notes.</div>
          <div style="background:rgba(99,102,241,.07);border:1px solid rgba(99,102,241,.22);border-radius:10px;padding:10px 12px;margin-bottom:12px;">
            <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
              <span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">GENERATE SCENE CONTEXT</span>
              ${_makeInfoBtn('summarise')}
            </div>
            <div style="font-size:11px;color:#4b5563;margin-bottom:8px;line-height:1.5;">Reads the <strong>currently visible messages</strong> and writes a current-situation note: where the characters are, the mood, recent events, unresolved tension. The result saves to your Scene Context and is injected into every JanitorAI reply. For a full-history summary of all messages, use the <strong>FAB → Summarise</strong> button instead.</div>
            <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
              <button class="ms2-btn-save" id="ms2-ctx-gen-btn" style="flex:1;min-width:80px;">${SVG_SPARKLE} Generate</button>
            </div>
            <div style="display:flex;align-items:center;gap:8px;margin-top:5px;">
              <button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-global-btn" title="Save current context as global memory (persists across all chats)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_SAVE} Save Global</button>
              <button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-global-btn" title="Load global memory into this chat's context" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_FOLDER} Load Global</button>
            </div>
            <div id="ms2-ctx-char-row" style="display:flex;align-items:center;gap:8px;margin-top:5px;">
              <button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-char-btn" title="Save context for this character only (stored separately from global)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Save for Character</button>
              <button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-char-btn" title="Load this character's saved memory (falls back to global if none)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Load for Character</button>
            </div>
            <div style="display:flex;align-items:center;gap:8px;margin-top:7px;padding-top:7px;border-top:1px solid rgba(99,102,241,.15);">
              <label class="ms2-toggle-switch" title="Automatically fill context with global/character memory when entering an empty chat">
                <input type="checkbox" id="ms2-ctx-autoload-chk" ${getAutoLoadGlobal() ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <span style="font-size:11px;color:#9ca3af;flex:1;">Auto-load memory into new empty chats</span>
              <button class="ms2-btn-save" id="ms2-ctx-autoload-save" style="padding:5px 10px;font-size:11px;">Save</button>
            </div>
            <div style="display:flex;align-items:center;gap:8px;margin-top:9px;padding-top:9px;border-top:1px solid rgba(99,102,241,.15);">
              <label class="ms2-toggle-switch" title="Auto-generate context when threshold is reached">
                <input type="checkbox" id="ms2-ctx-auto-chk" ${getAutoSumAuto() ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <span style="font-size:11px;color:#6b7280;flex:1;">Auto-generate every</span>
              <input type="number" id="ms2-ctx-auto-every" min="0" max="500" value="${getAutoSumEvery() || ''}" placeholder="N"
                style="width:52px;padding:4px 6px;background:#0d0d1a;border:1px solid #1e1b4b;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none;text-align:center;">
              <span style="font-size:11px;color:#6b7280;">msgs</span>
              <button class="ms2-btn-save" id="ms2-ctx-auto-save" style="padding:5px 10px;font-size:11px;">Save</button>
            </div>
          </div>
          <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Scene Context <span style="font-weight:400;color:#6b7280;">(injected into every Reply &amp; Shorten)</span></label>
          </div>
          <textarea class="ms2-input ms2-textarea-lg" id="ms2-s-context" maxlength="2000" placeholder="e.g. We're at a concert. Sylvie just won an award. Rvie is pretending not to care but is clearly jealous.">${escHtml(getContext())}</textarea>
          <div id="ms2-ctx-count" style="font-size:10px;color:#6b7280;text-align:right;margin-top:2px;">${getContext().length} / 2000</div>
          <div style="display:flex;align-items:flex-start;gap:8px;margin-top:8px;padding:8px;background:rgba(139,92,246,0.06);border:1px solid rgba(139,92,246,0.18);border-radius:7px;">
            <label class="ms2-toggle-switch" style="margin-top:1px;" title="Inject this Scene Context into JanitorAI's actual AI on every generation">
              <input type="checkbox" id="ms2-ctx-inject-chk" ${getInjectCtx() ? 'checked' : ''}>
              <span class="ms2-toggle-thumb"></span>
            </label>
            <div style="flex:1;">
              <span style="font-size:12px;color:#c4b5fd;font-weight:500;">Send to JanitorAI's AI</span>
              <div class="ms2-tip" style="margin-top:1px;">When ON, your Scene Context is appended to JanitorAI's actual generation on every message. <strong style="color:#e2e8f0;">Does this duplicate JanitorAI's built-in memory?</strong> No — keep Scene Context for <em>current situation</em> (where you are, what just happened) and leave character backstory/personality in JanitorAI's own memory box. Different purposes = no overlap. Saves automatically.</div>
            </div>
          </div>
          <div class="ms2-settings-actions">
            <button class="ms2-btn-save" id="ms2-save-context">Save</button>
            <button class="ms2-btn-cancel">Cancel</button>
          </div>
          <!-- PERSONA LIBRARY -->
          <input type="file" id="ms2-persona-import-file" accept=".json" style="display:none;">
          <div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
            <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
              <div style="display:flex;align-items:center;gap:6px;">
                <span style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;">${SVG_PERSONA} Persona Library</span>
                ${_makeInfoBtn('persona-library')}
              </div>
              <div style="display:flex;gap:4px;flex-shrink:0;">
                <button id="ms2-persona-add-btn" style="background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.35);border-radius:5px;color:#c4b5fd;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Add a new persona">+ Add</button>
                <button id="ms2-persona-export-btn" style="background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;color:#6ee7b7;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Export all personas as a JSON file">${SVG_SAVE} Export</button>
                <button id="ms2-persona-import-btn" style="background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.3);border-radius:5px;color:#fde68a;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Import personas from a JSON file (merges with existing)">${SVG_FOLDER} Import</button>
              </div>
            </div>
            <div class="ms2-tip" style="margin-bottom:8px;">Save character descriptions here. Click <strong>Use</strong> to instantly load one into the Scene Context box above.</div>
            <div id="ms2-persona-form" style="display:none;background:rgba(255,255,255,0.03);border:1px solid rgba(139,92,246,0.25);border-radius:7px;padding:10px;margin-bottom:8px;">
              <input type="text" id="ms2-persona-name-input" placeholder="Name (e.g. Rvie — Tsundere Mode)" class="ms2-input" style="margin-bottom:6px;">
              <textarea id="ms2-persona-desc-input" class="ms2-input ms2-textarea-sm" placeholder="Describe this character: how they speak, act, their mood, quirks…" style="min-height:72px;"></textarea>
              <div style="display:flex;gap:6px;margin-top:6px;">
                <button id="ms2-persona-save-btn" class="ms2-btn-save" style="flex:1;padding:5px;">Save</button>
                <button id="ms2-persona-cancel-btn" class="ms2-btn-cancel" style="flex:1;padding:5px;">Cancel</button>
              </div>
            </div>
            <div id="ms2-persona-list" style="display:flex;flex-direction:column;gap:5px;max-height:180px;overflow-y:auto;"></div>
          </div>
          <!-- Summary history -->
          <div style="margin-top:14px;">
            <div style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;">
              <span>Past summaries</span>
              <div style="display:flex;gap:8px;align-items:center;">
                <button id="ms2-ctx-export-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Export all summaries as JSON">↓ Export</button>
                <button id="ms2-ctx-clear-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Clear all summary history">${SVG_TRASH} Clear</button>
              </div>
            </div>
            <div id="ms2-ctx-hist-list" style="max-height:160px;overflow-y:auto;"></div>
          </div>
        </div>`;
  }
  function _buildAdvTab(tab0) {
    return `
        <div class="ms2-tab-panel ${tab0 === 'adv' ? 'active' : ''}" data-panel="adv">
          <div class="ms2-toggle-row" style="margin-bottom:10px;">
            <div style="display:flex;align-items:center;gap:6px;">
              <span class="ms2-toggle-label" style="font-size:12px;font-weight:600;color:#c4b5fd;">Enable Advanced Prompting</span>
              ${_makeInfoBtn('advanced-prompting')}
            </div>
            <label class="ms2-toggle-switch">
              <input type="checkbox" id="ap-enabled-chk" ${AP.enabled ? 'checked' : ''}>
              <span class="ms2-toggle-thumb"></span>
            </label>
          </div>
          <div class="ms2-tip" style="margin-bottom:10px;">When ON, the active preset replaces the <code style="color:#a78bfa">llm_prompt</code> field on every generation. Your proxy jailbreak is left untouched. <span id="ap-status-dot" class="ap-status-dot" title="No injection recorded yet"></span></div>
          <!-- THINKING TOGGLE -->
          <div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:12px;">
            <label class="ms2-toggle-switch" style="margin-top:2px;" title="Append a step-by-step reasoning instruction to every generation">
              <input type="checkbox" id="ap-thinking-chk">
              <span class="ms2-toggle-thumb"></span>
            </label>
            <div style="flex:1;">
              <span style="font-size:12px;color:#e2e8f0;font-weight:500;">Enable Thinking</span>
              <div class="ms2-tip" style="margin-top:2px;">Tells the AI to reason inside &lt;thinking&gt; tags before replying. Best for models that support extended reasoning (e.g. Claude 3.5+, o1, Gemini 2.0+).</div>
            </div>
          </div>
          <!-- FORBIDDEN WORDS -->
          <div id="ap-forbidden-wrap" style="margin-bottom:12px;">
            <label class="ms2-field-label">Forbidden Words / Phrases</label>
            <div style="display:flex;gap:4px;margin-bottom:4px;">
              <input type="text" id="ap-forbidden-input" placeholder="Add a word or phrase…" class="ms2-input" style="flex:1;margin-bottom:0;">
              <button class="ap-icon-btn" id="ap-forbidden-add-btn" title="Add to list">+</button>
            </div>
            <div id="ap-forbidden-tags" style="display:flex;flex-wrap:wrap;gap:4px;max-height:120px;overflow-y:auto;padding:4px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:6px;min-height:36px;">
              <!-- tags filled by JavaScript -->
            </div>
            <div class="ms2-tip" style="margin-top:4px;">These are injected into every generation — unlimited bans beyond JanitorAI's 10-word limit.</div>
            <div id="ap-forbidden-counter" style="display:none;margin-top:5px;font-size:11px;color:#a78bfa;padding:3px 6px;background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);border-radius:5px;line-height:1.5;"></div>
          </div>
          <label class="ms2-field-label">Active Preset</label>
          <div class="ap-row">
            <select class="ap-select" id="ap-preset-sel"></select>
            <button class="ap-icon-btn" id="ap-new-preset-btn" title="New preset">+</button>
            <button class="ap-icon-btn" id="ap-rename-preset-btn" title="Rename preset">✎</button>
            <button class="ap-icon-btn" id="ap-export-btn" title="Export preset as JSON">↑</button>
            <button class="ap-icon-btn" id="ap-import-btn" title="Import preset from JSON">↓</button>
            <button class="ap-icon-btn" id="ap-delete-preset-btn" title="Delete preset" style="color:#fca5a5;">${SVG_TRASH}</button>
          </div>
          <div id="ap-modules-wrap" style="display:none;">
            <label class="ms2-field-label">Prompt Modules <span id="ap-token-count" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;"></span></label>
            <div class="ap-token-bar"><div class="ap-token-fill" id="ap-token-fill" style="width:0%;"></div></div>
            <div id="ap-module-list" class="ap-module-list"></div>
            <div class="ap-row">
              <select class="ap-select" id="ap-unattached-sel"></select>
              <button class="ap-icon-btn" id="ap-attach-btn" title="Attach selected module — or create new if none exist">+</button>
              <button class="ap-icon-btn" id="ap-del-module-btn" title="Delete selected module" style="color:#fca5a5;">${SVG_TRASH}</button>
              <button class="ap-icon-btn" id="ap-new-module-btn" title="Create new blank module">✎</button>
            </div>
            <div class="ms2-settings-actions" style="margin-top:4px;">
              <button class="ms2-btn-save ap-save-dirty" id="ap-save-btn" disabled>Save Preset</button>
              <button class="ms2-btn-cancel" id="ap-discard-btn">Discard</button>
            </div>
          </div>
          <div id="ap-no-preset-msg" class="ap-empty">Select or create a preset to begin.</div>
        </div>`;
  }
  function _rewireGeneralTab(settingsPanel) {
    const epSel2 = settingsPanel.querySelector('#ms2-s-ep-sel');
    if (!epSel2) return;
    const customWrap2  = settingsPanel.querySelector('#ms2-s-custom-ep-wrap');
    const customModel2 = settingsPanel.querySelector('#ms2-s-custom-model');
    const modelSel2    = settingsPanel.querySelector('#ms2-s-model');
    const warnEl2      = settingsPanel.querySelector('#ms2-proxy-model-warn');
    if (epSel2 && customWrap2) {
      epSel2.addEventListener('change', () => {
        const isCustom = epSel2.value === 'custom';
        customWrap2.style.display = isCustom ? '' : 'none';
        if (warnEl2) warnEl2.style.display = isCustom ? '' : 'none';
      });
    }
    if (modelSel2 && customModel2) {
      modelSel2.addEventListener('change', () => {
        customModel2.style.display = modelSel2.value === '__custom__' ? '' : 'none';
      });
    }
    // Re-wire PIN buttons on the freshly rendered panel
    settingsPanel.querySelector('#ms2-pin-set-btn')?.addEventListener('click', () => _showPinModal('set', () => _rewireGeneralTab(settingsPanel)));
    settingsPanel.querySelector('#ms2-pin-unlock-btn')?.addEventListener('click', () => {
      _showPinModal('unlock', (_m, dec) => {
        if (dec) {
          const ki = settingsPanel.querySelector('#ms2-s-apikey');
          if (ki) { ki.value = dec; ki.removeAttribute('readonly'); }
          const st = settingsPanel.querySelector('.jv5-pin-status');
          if (st) { st.className = 'jv5-pin-status unlocked'; st.innerHTML = `${SVG_SHIELD} PIN active — key encrypted &amp; unlocked`; }
          settingsPanel.querySelector('#ms2-pin-unlock-btn')?.remove();
        }
      });
    });
    settingsPanel.querySelector('#ms2-pin-change-btn')?.addEventListener('click', () => _showPinModal('change', () => toastHTML(`${SVG_SHIELD} PIN changed`)));
    settingsPanel.querySelector('#ms2-pin-remove-btn')?.addEventListener('click', () => _showPinModal('remove', () => _rewireGeneralTab(settingsPanel)));

    // Idle auto-lock timeout selector
    settingsPanel.querySelector('#ms2-pin-idle-sel')?.addEventListener('change', (e) => {
      const mins = Number(e.target.value) || 0;
      try { GM_setValue(_PIN_IDLE_GM, mins); } catch {}
      _pinTouchActivity(); // don't immediately lock if the user just changed this setting
      toastHTML(mins > 0
        ? `${SVG_SHIELD} Auto-lock after ${mins} min of inactivity`
        : `${SVG_UNLOCK} Auto-lock disabled`);
    });
  }

  // ─── PIN MODAL ────────────────────────────────────────────────────────────
  // Handles Set PIN, Unlock PIN, Change PIN, and Remove PIN flows.
  // mode: 'set' | 'unlock' | 'change' | 'remove'
  // _promptModal — Promise-based inline modal replacing window.prompt() (blocked on mobile Chrome)
  function _promptModal({ title = 'Enter value', placeholder = '', type = 'password', confirm = 'OK' } = {}) {
    return new Promise(resolve => {
      const ov = document.createElement('div');
      ov.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,0.65);display:flex;align-items:center;justify-content:center;';
      ov.innerHTML = `
        <div style="background:#13131f;border:1px solid rgba(139,92,246,0.5);border-radius:14px;
          padding:18px 16px;width:min(300px,calc(100vw - 32px));box-shadow:0 12px 36px rgba(0,0,0,0.75);
          font-family:system-ui,sans-serif;">
          <div style="font-size:13px;font-weight:700;color:#c4b5fd;margin-bottom:10px;">${title}</div>
          <input id="_pm_input" type="${type}" placeholder="${placeholder}"
            style="width:100%;box-sizing:border-box;padding:9px 11px;background:#0d0d1a;
              border:1px solid rgba(139,92,246,0.4);border-radius:8px;color:#e2e8f0;
              font-size:13px;outline:none;margin-bottom:10px;font-family:monospace;">
          <div style="display:flex;gap:8px;">
            <button id="_pm_ok" style="flex:1;padding:8px;font-size:12px;font-weight:600;
              background:linear-gradient(135deg,#7c3aed,#6d28d9);border:none;border-radius:8px;
              color:#fff;cursor:pointer;">${confirm}</button>
            <button id="_pm_cancel" style="flex:1;padding:8px;font-size:12px;
              background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);
              border-radius:8px;color:#9ca3af;cursor:pointer;">Cancel</button>
          </div>
        </div>`;
      document.body.appendChild(ov);
      const inp = ov.querySelector('#_pm_input');
      setTimeout(() => inp?.focus(), 80);
      const _ok = () => { const v = inp?.value?.trim() || null; ov.remove(); resolve(v); };
      const _cancel = () => { ov.remove(); resolve(null); };
      ov.querySelector('#_pm_ok').addEventListener('click', _ok);
      ov.querySelector('#_pm_cancel').addEventListener('click', _cancel);
      inp?.addEventListener('keydown', e => { if (e.key === 'Enter') _ok(); if (e.key === 'Escape') _cancel(); });
      ov.addEventListener('click', e => { if (e.target === ov) _cancel(); });
    });
  }

  function _showPinModal(mode, onSuccess) {
    const existing = document.querySelector('.jv5-pin-modal');
    if (existing) existing.remove();

    const modeConfig = {
      set:    { title: `${SVG_LOCK} Set PIN Encryption`, sub: 'Your API key will be encrypted with AES-256-GCM. The PIN is never stored — you enter it once per browser session.', confirm: 'Encrypt & Save', fields: ['new','confirm'] },
      unlock: { title: `${SVG_UNLOCK} Unlock API Key`, sub: 'Enter your PIN to decrypt the API key for this session.', confirm: 'Unlock', fields: ['pin'] },
      change: { title: '✎ Change PIN', sub: 'Enter your current PIN, then set a new one. The key will be re-encrypted with the new PIN immediately.', confirm: 'Change PIN', fields: ['current','new','confirm'] },
      remove: { title: `${SVG_UNLOCK} Remove PIN`, sub: 'Enter your current PIN to confirm. Your key will be decrypted and stored as plain text again.', confirm: 'Remove PIN', fields: ['current'] },
    };
    const cfg = modeConfig[mode];
    if (!cfg) return;

    const overlay = document.createElement('div');
    overlay.className = 'jv5-pin-modal';

    overlay.innerHTML = `
      <div class="jv5-pin-box">
        <h3>${cfg.title}</h3>
        <p>${cfg.sub}</p>
        ${cfg.fields.includes('current') ? `<input type="password" id="jv5-pin-current" placeholder="Current PIN" autocomplete="current-password" style="font-family:monospace;">` : ''}
        ${cfg.fields.includes('pin')     ? `<input type="password" id="jv5-pin-input"   placeholder="PIN"         autocomplete="current-password" style="font-family:monospace;">` : ''}
        ${cfg.fields.includes('new')     ? `<input type="password" id="jv5-pin-new"     placeholder="New PIN (min 6 characters)" autocomplete="new-password" style="font-family:monospace;">` : ''}
        ${cfg.fields.includes('confirm') ? `<input type="password" id="jv5-pin-confirm" placeholder="Confirm new PIN" autocomplete="new-password" style="font-family:monospace;">` : ''}
        <div class="jv5-pin-err" id="jv5-pin-err"></div>
        <div class="jv5-pin-row">
          <button class="jv5-pin-btn-confirm" id="jv5-pin-confirm-btn">${cfg.confirm}</button>
          <button class="jv5-pin-btn-cancel"  id="jv5-pin-cancel-btn">Cancel</button>
        </div>
      </div>`;

    document.body.appendChild(overlay);

    const errEl   = overlay.querySelector('#jv5-pin-err');
    const confirmBtn = overlay.querySelector('#jv5-pin-confirm-btn');

    overlay.querySelector('#jv5-pin-cancel-btn').addEventListener('click', () => overlay.remove());
    overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });

    // Focus first input
    setTimeout(() => overlay.querySelector('input')?.focus(), 80);

    // Enter key submits
    overlay.addEventListener('keydown', e => { if (e.key === 'Enter') confirmBtn.click(); });

    confirmBtn.addEventListener('click', async () => {
      errEl.textContent = '';
      confirmBtn.disabled = true;
      confirmBtn.textContent = '…';

      try {
        if (mode === 'set') {
          const newPin  = overlay.querySelector('#jv5-pin-new')?.value || '';
          const confPin = overlay.querySelector('#jv5-pin-confirm')?.value || '';
          if (newPin.length < 6)       { errEl.textContent = 'PIN must be at least 6 characters.'; return; }
          if (newPin !== confPin)      { errEl.textContent = 'PINs do not match.'; return; }
          const currentKey = CFG.apiKey;
          if (!currentKey)             { errEl.textContent = 'Paste your API key first, then set a PIN.'; return; }
          const ok = await _pinEncryptKey(currentKey, newPin);
          if (!ok)                     { errEl.textContent = 'Encryption failed — try again.'; return; }
          overlay.remove();
          toastHTML(`${SVG_SHIELD} API key encrypted with PIN`);
          onSuccess && onSuccess('set');

        } else if (mode === 'unlock') {
          const pin = overlay.querySelector('#jv5-pin-input')?.value || '';
          if (!pin)                    { errEl.textContent = 'Enter your PIN.'; return; }
          const decrypted = await _pinDecryptKey(pin);
          if (decrypted === null)      { errEl.textContent = 'Wrong PIN — decryption failed.'; return; }
          CFG.apiKey = decrypted;
          overlay.remove();
          toastHTML(`${SVG_UNLOCK} API key unlocked for this session`);
          onSuccess && onSuccess('unlock', decrypted);

        } else if (mode === 'change') {
          const curPin  = overlay.querySelector('#jv5-pin-current')?.value || '';
          const newPin  = overlay.querySelector('#jv5-pin-new')?.value || '';
          const confPin = overlay.querySelector('#jv5-pin-confirm')?.value || '';
          if (newPin.length < 6)       { errEl.textContent = 'New PIN must be at least 6 characters.'; return; }
          if (newPin !== confPin)      { errEl.textContent = 'New PINs do not match.'; return; }
          // Verify old PIN first
          const decrypted = await _pinDecryptKey(curPin);
          if (decrypted === null)      { errEl.textContent = 'Current PIN is wrong.'; return; }
          // Remove old salt so a new one is generated
          try { GM_deleteValue(_KEY_SALT_GM); } catch {}
          _pinSession.active = false; _pinSession.cryptoKey = null; _sessionApiKey = ''; _sessionEndpointBinding = '';
          const ok = await _pinEncryptKey(decrypted, newPin);
          if (!ok)                     { errEl.textContent = 'Re-encryption failed — try again.'; return; }
          CFG.apiKey = decrypted;
          overlay.remove();
          toastHTML(`${SVG_SHIELD} PIN changed — key re-encrypted`);
          onSuccess && onSuccess('change');

        } else if (mode === 'remove') {
          const curPin = overlay.querySelector('#jv5-pin-current')?.value || '';
          const decrypted = await _pinDecryptKey(curPin);
          if (decrypted === null)      { errEl.textContent = 'Wrong PIN — cannot remove.'; return; }
          await _pinRemove();
          CFG.apiKey = decrypted;
          // Store decrypted key as plaintext now that PIN is removed
          try { GM_setValue(_KEY_PLAIN_GM, decrypted); } catch {}
          overlay.remove();
          toastHTML(`${SVG_UNLOCK} PIN removed — key stored as plain text`);
          onSuccess && onSuccess('remove');
        }
      } finally {
        if (overlay.isConnected) {
          confirmBtn.disabled = false;
          confirmBtn.textContent = cfg.confirm;
        }
      }
    });
  }

  function openSettingsModal(initialTab) {
    if (document.getElementById('ms2-settings-backdrop')) return;
    const tab0 = initialTab || 'general';

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ms2-settings-backdrop';
    setTimeout(() => {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
    }, 350);
    addEscapeClose(backdrop);

    const tabs = ['general', 'reply', 'styles', 'context', 'adv', 'about'];
    const tabLabels = { general: `${SVG_SETTINGS} General`, reply: `${SVG_REPLY} Reply`, styles: `${SVG_STYLES} Styles`, context: `${SVG_CONTEXT} Context`, adv: `${SVG_CONFIG} Configure`, about: `${SVG_INFO} About` };

    // Build grouped <optgroup> model list
    const _modelGroupMap = new Map();
    for (const m of MODELS) {
      const g = m.group || 'Other';
      if (!_modelGroupMap.has(g)) _modelGroupMap.set(g, []);
      _modelGroupMap.get(g).push(m);
    }
    const modelOpts = [..._modelGroupMap.entries()].map(([gName, models]) =>
      `<optgroup label="${escHtml(gName)}">${
        models.map(m =>
          `<option value="${escHtml(m.id)}" ${CFG.model === m.id ? 'selected' : ''}>${escHtml(m.label)}</option>`
        ).join('')
      }</optgroup>`
    ).join('');
    const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
      `<option value="${escHtml(t.id)}" ${CFG.defaultTone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
    ).join('');

    const panel = document.createElement('div');
    panel.className = 'ms2-settings-v2';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-modal', 'true');
    panel.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">${SVG_SETTINGS} Settings</div>
        <button class="ms2-modal-close">×</button>
      </div>
      <div class="ms2-tab-bar">
        ${tabs.map(t => `<button class="ms2-tab ${t === tab0 ? 'active' : ''}" data-tab="${t}">${tabLabels[t]}</button>`).join('')}
      </div>
      <div class="ms2-settings-body">
        ${_buildGeneralTab(tab0, modelOpts)}
        ${_buildReplyTab(tab0, toneOpts)}
        ${_buildStylesTab(tab0)}
        ${_buildContextTab(tab0)}
        ${_buildAdvTab(tab0)}
        <!-- ABOUT -->
        <div class="ms2-tab-panel ${tab0 === 'about' ? 'active' : ''}" data-panel="about">
          <div class="ms2-about-box">
            <div class="ms2-about-title">JanitorV5 — Smart RP Toolkit</div>
            <div class="ms2-about-version">v5.7.4 — Security hardening: prototype-pollution guard (allowlist merge) · toast XSS isolation (textContent/toastHTML split) · relay SSRF block (private IP rejection) · admin replay persistence (GM-backed seen-ids) · dev-mode diagnostic gate · @connect wildcard removed · seenMsgIds GM persistence · _esc single-quote fix · report PSK encryption</div>

            <div class="ms2-about-row"><strong>Created by</strong> eivls</div>
            <div class="ms2-about-row"><strong>TikTok</strong> <a href="https://tiktok.com/@eivls" target="_blank" style="color:#a78bfa;text-decoration:none;">@eivls</a></div>
            <div class="ms2-about-row" style="display:flex;align-items:center;gap:6px;">
              <strong>License</strong>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#a78bfa;vertical-align:middle;flex-shrink:0;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
              <span>All Rights Reserved — © 2025 eivls</span>
            </div>

            <div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.2);border-radius:8px;padding:10px 12px;margin:10px 0;">
              <div style="font-size:11px;font-weight:700;color:#6ee7b7;letter-spacing:.5px;margin-bottom:6px;">${SVG_ROCKET} QUICK SETUP</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>1. Long-press the FAB</strong> — Hold the ${SVG_SETTINGS} gear button at the bottom-right for ~1 s until the purple ring fills, then release. This opens Settings.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>2. Connect your AI</strong> — Go to <em>General</em>, pick a provider, paste your API key, and hit <strong>Test API</strong>. OpenRouter has free models — no card needed to start.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>3. Tap (don't hold) the FAB in any chat</strong> — The speed-dial opens. Choose a tool: Reply, Shorten, Summarise, Styles, Personas, or Community Chat.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>4. Fine-tune in Settings</strong> — Set a default tone in Reply, build Style presets, add Persona Library entries, configure your system prompt in Configure. Everything autosaves.</div>
              <div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>5. Drag the FAB</strong> — It repositions to any edge so it never blocks your view.</div>
            </div>

            <div class="ms2-about-row"><strong>${SVG_SETTINGS} FAB (gear button)</strong> — <em>Tap once</em> = speed-dial menu (6 tools). <em>Long-press ~1 s</em> = open Settings. <em>Drag</em> to reposition anywhere on screen.</div>
            <div class="ms2-about-row"><strong>${SVG_SCISSORS} Shorten</strong> — Condenses the latest AI message. Pick cut depth (Slash ~30% / Halve ~50% / Polish ~70%) and toggle dialogue preservation.</div>
            <div class="ms2-about-row"><strong>${SVG_REPLY} Reply</strong> — Pick a tone, write an optional instruction, generate a reply as your character. Hard-ban list blocks repetitive AI phrases. Hit ${SVG_KEYBOARD} Send to inject directly into chat.</div>
            <div class="ms2-about-row"><strong>${SVG_STYLES} Styles</strong> — Save named presets with your character's voice and tone. Activate one to auto-fill Reply settings every time.</div>
            <div class="ms2-about-row"><strong>${SVG_SUMMARISE} Summarise</strong> — Auto loads your full chat history and writes a persistent memory entry (who the characters are, relationship arc, key events). Output copies to clipboard — paste into JanitorAI's Chat Memory panel.</div>
            <div class="ms2-about-row"><strong>${SVG_PERSONA} Personas</strong> — Quick-switch between your saved Persona Library entries directly from the speed-dial. Live search included — no need to open Settings.</div>
            <div class="ms2-about-row"><strong>${SVG_CHAT} Community Chat</strong> — Real-time global chat shared across all JanitorAI users running JanitorV5. Open from the speed-dial FAB.</div>

            <div style="background:rgba(6,182,212,0.06);border:1px solid rgba(6,182,212,0.2);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#67e8f9;letter-spacing:.5px;margin-bottom:6px;">${SVG_CHAT} COMMUNITY CHAT v2 — WHAT'S INCLUDED</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Verified names</strong> — Green ✓ badge on users whose display name has been cryptographically confirmed.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Typing indicator</strong> — Live "X is typing…" bar appears as others compose a message.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Online count</strong> — Shows how many users are active in the room right now via heartbeat.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reply with notification</strong> — Hit ↩ on any message to quote-reply; the original sender gets a toast notification.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reactions &amp; emoji picker</strong> — React to any message with an emoji. Grouped reaction counts shown on each bubble.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Pinned messages bar</strong> — Admins can pin a message so it stays visible at the top of the chat for everyone.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Admin controls</strong> — Admins see a distinct styled bubble and can pin/unpin, ban, and moderate messages.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Mute / Unmute</strong> — Mute any user to hide their messages locally. Manage your blocked list from the chat header.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Report</strong> — Flag any message with ⚑; sends a report and confirms with a toast.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Chat export</strong> — Download the full visible chat history as a plain-text file from the header button.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Auto-scroll badge</strong> — When you scroll up to read history, a "● N new" badge appears and jumps you back to the bottom.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Delivery confirmation</strong> — Your own bubbles show "Sending…" then flip to a green <strong>✓ Delivered</strong> once the relay confirms the send (2xx). Fades out automatically after 2.5 s.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Rate-limit UX</strong> — Send button disables with a live countdown after each message. On HTTP 429, network error, or 10 s timeout, an in-chat banner appears with the airplane-mode tip and a one-tap <em>Retry</em> button.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Deduplication</strong> — No message ever appears twice, even across overlapping polls or reconnects.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Global &amp; character rooms</strong> — Auto-detected from the current URL. Character rooms are private to people viewing the same character page; Global is site-wide.</div>
            </div>

            <div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#6ee7b7;letter-spacing:.5px;margin-bottom:6px;">🔐 PRIVACY &amp; SECURITY</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong style="color:#6ee7b7;">End-to-end encrypted</strong> — Every message, reaction, admin command, and WebRTC signaling packet is AES-GCM-256 encrypted before it leaves your browser. The relay (ntfy.sh) only ever sees opaque ciphertext.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Connection IP</strong> — ntfy.sh sees the IP your browser uses to connect (same as any website). Message content and ICE candidate IPs are fully encrypted. Use a VPN to hide your connection IP from the relay.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Anonymous identity</strong> — You get a random peer ID. No login, no email, no account. Your real IP is never visible to other chat users.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Traffic analysis mitigation</strong> — Messages are padded to 256-byte blocks before encryption, so an observer cannot infer message length from ciphertext size.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Hashed topic names</strong> — The ntfy channel names are SHA-256 derived and not guessable without the script. Character rooms get a unique hashed topic per character.</div>
              <div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Auto-delete</strong> — Ciphertext is stored on ntfy.sh for ~30 minutes then automatically deleted by the relay.</div>
            </div>

            <div style="background:rgba(59,130,246,0.07);border:1px solid rgba(59,130,246,0.25);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#93c5fd;letter-spacing:.5px;margin-bottom:6px;">🌐 OUTBOUND CONNECTIONS — FULL LIST</div>
              <div style="font-size:11px;color:#9ca3af;line-height:1.7;">
                This script does <strong style="color:#e5e7eb;">not</strong> use a wildcard <code style="background:rgba(0,0,0,0.3);padding:1px 4px;border-radius:3px;">@connect *</code>.
                Every domain it can contact is listed explicitly in the script header and below.<br><br>
                <strong style="color:#e5e7eb;">AI providers</strong> (only contacted when you trigger a generation):<br>
                openrouter.ai · api.openai.com · api.anthropic.com · api.x.ai · api.mistral.ai · api.groq.com · api.cohere.ai · generativelanguage.googleapis.com · api.together.xyz · api.deepseek.com · inference.cerebras.ai · lorebary.com<br><br>
                <strong style="color:#e5e7eb;">Community Chat relay</strong> (only when Chat tab is open):<br>
                ntfy.sh — receives only AES-256-GCM ciphertext, never plaintext<br><br>
                <strong style="color:#e5e7eb;">Nothing else.</strong> No analytics, no telemetry, no third-party trackers.
                Custom endpoints you configure are validated at runtime: HTTPS-only, private IP ranges are blocked, and if you use a PIN your key is bound to the endpoint active at unlock time so a tampered config cannot redirect it.
              </div>
            </div>
            </div>

            <div style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.3);border-radius:8px;padding:10px 12px;margin:8px 0;">
              <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin-bottom:5px;">💜 HELP GROW THE COMMUNITY</div>
              <div class="ms2-about-box" style="margin:0;color:#d1d5db;">
                If JanitorV5 has improved your experience, share it. Post the GreasyFork link on
                <strong style="color:#e5e7eb;">TikTok</strong>, <strong style="color:#e5e7eb;">X / Twitter</strong>,
                <strong style="color:#e5e7eb;">Discord</strong>, or <strong style="color:#e5e7eb;">Reddit</strong>.
                A short screen recording, review, or "how I use this" video is the single best way to get more
                roleplayers into Community Chat — and more users means better conversations for everyone.
                No sponsorship, no algorithm tricks needed — just share it if you find it useful.
              </div>
            </div>

            <div class="ms2-about-row"><strong>${SVG_CONTEXT} Context tab</strong> — Per-chat scene notes (where you are, what just happened) sent to every Reply and Shorten request. Toggle <strong>Send to JanitorAI's AI</strong> to inject into JanitorAI's actual generation automatically. Use <strong>Generate</strong> or the FAB <strong>Summarise</strong> shortcut to auto-write the note from your chat history.</div>
            <div class="ms2-about-row"><strong>${SVG_PERSONA} Persona Library</strong> — Save reusable character descriptions by name. Use, Edit, or Delete entries. Import a JSON backup or Export to save your whole library.</div>
            <div class="ms2-about-row"><strong>${SVG_CONFIG} Configure tab</strong> — Intercepts every JanitorAI generation and replaces the system prompt with your configured module stack. Deleted messages are scrubbed automatically. Always save before activating.</div>
          </div>
        </div>
      </div>`;

    backdrop.appendChild(panel);
    document.body.appendChild(backdrop);

    const closeAll = () => backdrop.remove();
    panel.querySelector('.ms2-modal-close').addEventListener('click', closeAll);
    panel.querySelectorAll('.ms2-btn-cancel').forEach(b => b.addEventListener('click', closeAll));

    panel.querySelectorAll('.ms2-tab').forEach(tab => {
      tab.addEventListener('click', () => {
        panel.querySelectorAll('.ms2-tab').forEach(t => t.classList.remove('active'));
        panel.querySelectorAll('.ms2-tab-panel').forEach(p => p.classList.remove('active'));
        tab.classList.add('active');
        panel.querySelector(`[data-panel="${tab.dataset.tab}"]`)?.classList.add('active');

      });
    });

    const epSel   = panel.querySelector('#ms2-s-ep-sel');
    const epWrap  = panel.querySelector('#ms2-s-custom-ep-wrap');
    const modelSel = panel.querySelector('#ms2-s-model');
    const customModelInput = panel.querySelector('#ms2-s-custom-model');

    const proxyModelWarn = panel.querySelector('#ms2-proxy-model-warn');
    const isKnownEp = () => KNOWN_EPS.includes(epSel.value);
    epSel.addEventListener('change', () => {
      epWrap.style.display = epSel.value === 'custom' ? '' : 'none';
      proxyModelWarn.style.display = isKnownEp() ? 'none' : '';
    });
    modelSel.addEventListener('change', () => {
      customModelInput.style.display = modelSel.value === '__custom__' ? '' : 'none';
    });

    panel.querySelector('#ms2-test-api-btn').addEventListener('click', async () => {
      const testBtn   = panel.querySelector('#ms2-test-api-btn');
      const resultDiv = panel.querySelector('#ms2-api-test-result');
      const authSel   = panel.querySelector('#ms2-s-auth-mode');
      const typedKey  = panel.querySelector('#ms2-s-apikey').value.trim();
      if (!typedKey) { resultDiv.style.color = '#f87171'; resultDiv.innerHTML = `${SVG_CROSS} Enter an API key first`; return; }

      const selectedEp = epSel.value === 'custom'
        ? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
        : epSel.value;
      const selectedModel = modelSel.value === '__custom__'
        ? (customModelInput.value.trim() || MODELS[0].id)
        : modelSel.value;
      const baseTestEp = selectedEp.replace(/\/$/, '');
      const isAnthropicTest = selectedEp.includes('anthropic.com');
      const testEp = isAnthropicTest ? baseTestEp + '/v1/messages' : baseTestEp + '/chat/completions';
      const testBody = isAnthropicTest
        ? JSON.stringify({ model: selectedModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] })
        : JSON.stringify({ model: selectedModel, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1, temperature: 0 });

      // Helper: attempt one request with a specific auth mode; returns {ok, status, msg}
      async function _tryAuth(mode) {
        const hdrs = { 'Content-Type': 'application/json', ..._buildAuthHeaders(typedKey, selectedEp, mode) };
        try {
          const r = await gmFetch(testEp, { method: 'POST', headers: hdrs, body: testBody });
          if (r.ok) return { ok: true };
          // 3xx: redirect not followed (some managers ignore redirect:'follow')
          if (r.status >= 300 && r.status < 400) {
            const loc = r.headers?.get('location') || '';
            let hint = 'Your endpoint URL is redirecting';
            if (loc) {
              // Strip the appended path so user gets the base URL to paste
              const cleanLoc = loc.replace(/\/chat\/completions$/, '').replace(/\/v1\/messages$/, '');
              hint = `URL redirects → ${cleanLoc} — paste that as your Base URL`;
            } else {
              hint = `URL redirects (${r.status}) — try adding or removing /v1, or switch http→https`;
            }
            return { ok: false, status: r.status, msg: hint };
          }
          const raw = await r.text().catch(() => '');
          let msg = `API error ${r.status}`;
          try { msg = JSON.parse(raw)?.error?.message || msg; } catch {}
          return { ok: false, status: r.status, msg };
        } catch (e) {
          if (e.name === 'AbortError') throw e;
          return { ok: false, status: 0, msg: e.message };
        }
      }

      function _isAuthError(res) {
        if (res.status === 401 || res.status === 403) return true;
        const m = (res.msg || '').toLowerCase();
        return m.includes('auth') || m.includes('key') || m.includes('token') ||
               m.includes('credential') || m.includes('unauthorized') || m.includes('forbidden');
      }

      const AUTH_MODE_LABELS = { auto: 'Auto-detect', bearer: 'Bearer', raw: 'Key only', 'x-api-key': 'x-api-key' };

      testBtn.disabled = true;
      resultDiv.style.color = '#9ca3af';
      resultDiv.textContent = 'Testing…';

      try {
        const primaryMode = authSel?.value || 'auto';
        resultDiv.textContent = `Testing (${AUTH_MODE_LABELS[primaryMode]})…`;
        let result = await _tryAuth(primaryMode);

        if (!result.ok && _isAuthError(result)) {
          // Auto-cycle through the remaining modes to find one that works
          const fallbacks = ['bearer', 'raw', 'x-api-key'].filter(m => m !== primaryMode);
          let found = null;
          for (const fb of fallbacks) {
            resultDiv.textContent = `Trying ${AUTH_MODE_LABELS[fb]}…`;
            const r = await _tryAuth(fb);
            if (r.ok) { found = fb; result = r; break; }
            if (!_isAuthError(r)) { result = r; break; } // non-auth error — stop cycling
          }
          if (found) {
            // Update the dropdown to the working mode
            if (authSel) authSel.value = found;
            resultDiv.style.color = '#4ade80';
            resultDiv.innerHTML = `${SVG_CHECK} Works with <strong>${AUTH_MODE_LABELS[found]}</strong> format — click Save to keep this setting`;
            return;
          }
        }

        if (result.ok) {
          resultDiv.style.color = '#4ade80';
          resultDiv.innerHTML = `${SVG_CHECK} Connection works`;
        } else {
          throw new Error(result.msg);
        }
      } catch (err) {
        if (err.name === 'AbortError') return;
        resultDiv.style.color = '#f87171';
        resultDiv.innerHTML = `${SVG_CROSS} ${escHtml(err.message)}`;
      } finally {
        testBtn.disabled = false;
      }
    });

    panel.querySelector('#ms2-save-general').addEventListener('click', async () => {
      const typedKey = panel.querySelector('#ms2-s-apikey').value.trim();

      // If PIN is active and the field is not readonly (user typed a new key), re-encrypt
      if (_pinIsActive() && typedKey && typedKey !== CFG.apiKey) {
        // New key was typed while PIN is active — re-encrypt using the session CryptoKey
        if (_pinSession.active && _pinSession.cryptoKey) {
          // Encrypt directly with the existing derived CryptoKey (no PIN re-entry needed)
          try {
            const saltArr = (() => {
              try { const s = GM_getValue(_KEY_SALT_GM, ''); return s ? _b64ToArr(s) : null; } catch { return null; }
            })();
            if (!saltArr) throw new Error('Salt missing');
            const iv = crypto.getRandomValues(new Uint8Array(12));
            const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, _pinSession.cryptoKey, new TextEncoder().encode(typedKey));
            const blob = new Uint8Array(12 + ct.byteLength);
            blob.set(iv, 0); blob.set(new Uint8Array(ct), 12);
            GM_setValue(_KEY_ENC_GM, _arrToB64(blob));
            CFG.apiKey = typedKey;
          } catch {
            toastHTML(`${SVG_WARNING} Re-encryption failed — try again`, 3500);
            return;
          }
        } else {
          // PIN active but session not unlocked — can't save a new key safely
          toastHTML(`${SVG_WARNING} Unlock your PIN first before changing the API key`, 3500);
          return;
        }
      } else if (!_pinIsActive()) {
        // No PIN — store plaintext as before
        CFG.apiKey = typedKey;
      }

      CFG.authMode = panel.querySelector('#ms2-s-auth-mode')?.value || 'auto';
      const rawEp = (epSel.value === 'custom'
        ? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
        : epSel.value).replace(/\/$/, '');
      // Validate before saving — reject HTTP and private-range endpoints
      if (!/^https:\/\/.+/.test(rawEp)) {
        toastHTML(`${SVG_WARNING} Endpoint must start with https:// — not saved`, 4000);
        return;
      }
      if (_SSRF_BLOCK_RE.test(rawEp)) {
        toastHTML(`${SVG_WARNING} Endpoint points to a private/local address — blocked`, 4000);
        return;
      }
      CFG.endpoint = rawEp;
      CFG.model = modelSel.value === '__custom__'
        ? (customModelInput.value.trim() || MODELS[0].id)
        : modelSel.value;
      toastHTML(`${SVG_CHECK} General settings saved`);
      closeAll();
    });

    // ── PIN button handlers ──────────────────────────────────────────────────
    panel.querySelector('#ms2-pin-set-btn')?.addEventListener('click', () => {
      _showPinModal('set', () => {
        // Re-render the General tab to show updated PIN status
        const genPanel = panel.querySelector('[data-panel="general"]');
        if (genPanel) {
          const _modelGroupMap2 = new Map();
          for (const m of MODELS) { const g = m.group || 'Other'; if (!_modelGroupMap2.has(g)) _modelGroupMap2.set(g, []); _modelGroupMap2.get(g).push(m); }
          const mOpts2 = [..._modelGroupMap2.entries()].map(([gN, ms]) => `<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`).join('');
          const tmp = document.createElement('div');
          tmp.innerHTML = _buildGeneralTab('general', mOpts2);
          genPanel.replaceWith(tmp.firstElementChild);
          _rewireGeneralTab(panel);
        }
      });
    });

    panel.querySelector('#ms2-pin-unlock-btn')?.addEventListener('click', () => {
      _showPinModal('unlock', (_mode, decrypted) => {
        if (decrypted) {
          const keyInput = panel.querySelector('#ms2-s-apikey');
          if (keyInput) { keyInput.value = decrypted; keyInput.removeAttribute('readonly'); }
          // Refresh PIN status row
          const statusEl = panel.querySelector('.jv5-pin-status');
          if (statusEl) {
            statusEl.className = 'jv5-pin-status unlocked';
            statusEl.innerHTML = `${SVG_SHIELD} PIN active — key encrypted &amp; unlocked for this session`;
          }
          panel.querySelector('#ms2-pin-unlock-btn')?.remove();
        }
      });
    });

    panel.querySelector('#ms2-pin-change-btn')?.addEventListener('click', () => {
      _showPinModal('change', () => toastHTML(`${SVG_SHIELD} PIN changed`));
    });

    panel.querySelector('#ms2-pin-remove-btn')?.addEventListener('click', () => {
      _showPinModal('remove', () => {
        const genPanel = panel.querySelector('[data-panel="general"]');
        if (genPanel) {
          const _modelGroupMap3 = new Map();
          for (const m of MODELS) { const g = m.group || 'Other'; if (!_modelGroupMap3.has(g)) _modelGroupMap3.set(g, []); _modelGroupMap3.get(g).push(m); }
          const mOpts3 = [..._modelGroupMap3.entries()].map(([gN, ms]) => `<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`).join('');
          const tmp = document.createElement('div');
          tmp.innerHTML = _buildGeneralTab('general', mOpts3);
          genPanel.replaceWith(tmp.firstElementChild);
          _rewireGeneralTab(panel);
        }
      });
    });

    // Idle auto-lock timeout selector (initial render — _rewireGeneralTab
    // re-binds this after any PIN set/change/remove re-render).
    panel.querySelector('#ms2-pin-idle-sel')?.addEventListener('change', (e) => {
      const mins = Number(e.target.value) || 0;
      try { GM_setValue(_PIN_IDLE_GM, mins); } catch {}
      _pinTouchActivity(); // don't immediately lock if the user just changed this setting
      toastHTML(mins > 0
        ? `${SVG_SHIELD} Auto-lock after ${mins} min of inactivity`
        : `${SVG_UNLOCK} Auto-lock disabled`);
    });

    panel.querySelector('#ms2-save-relay')?.addEventListener('click', () => {
      const rawRelay = (panel.querySelector('#ms2-s-relay-url')?.value || '').trim();
      const relay = rawRelay.replace(/\/$/, ''); // strip trailing slash
      if (relay) {
        // Basic protocol check
        if (!/^https?:\/\/.+/.test(relay)) {
          toastHTML(`${SVG_WARNING} Invalid URL — must start with http:// or https://`, 3500);
          return;
        }
        // SECURITY: Block private/loopback addresses to prevent SSRF via
        // user-configured relay URL + @connect wildcard combination.
        let _parsedHost = '';
        try { _parsedHost = new URL(relay).hostname.toLowerCase(); } catch {
          toastHTML(`${SVG_WARNING} Invalid URL format`, 3500); return;
        }
        const _ssrfBlocked = /^(localhost|127\.|0\.0\.0\.0|::1$|fc00:|fd|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(_parsedHost);
        if (_ssrfBlocked) {
          toastHTML(`${SVG_WARNING} Relay URL cannot point to a private/local address`, 3500);
          return;
        }
      }
      try {
        GM_setValue(P2P_GM_RELAY, relay || '');
        const rawToken = (panel.querySelector('#ms2-s-relay-token')?.value || '').trim();
        if (rawToken && (!relay || relay === P2P_RELAYS[0])) {
          // Refuse to store a token alongside the default public relay — it
          // would never be sent (by design) and likely indicates a mistake.
          toastHTML(`${SVG_WARNING} Relay token ignored — set a custom relay URL first`, 4000);
          GM_setValue(P2P_GM_RELAY_TOKEN, '');
        } else {
          GM_setValue(P2P_GM_RELAY_TOKEN, rawToken);
          toastHTML(`${SVG_CHECK} Relay URL ${relay ? 'saved' : 'reset to default'}${rawToken ? ' with access token' : ''}`);
        }
      } catch (e) {
        toastHTML(`${SVG_CROSS} Failed to save relay URL`);
      }
    });

    panel.querySelector('#ms2-save-crypto')?.addEventListener('click', () => {
      try {
        GM_setValue('jv5_ratchet_global',      !!panel.querySelector('#ms2-s-ratchet')?.checked);
        GM_setValue('jv5_use_argon2',          !!panel.querySelector('#ms2-s-argon2')?.checked);
        GM_setValue('jv5_use_ed25519_admin',   !!panel.querySelector('#ms2-s-ed25519')?.checked);
        delete _cfgCache['jv5_ratchet_global'];
        delete _cfgCache['jv5_use_argon2'];
        delete _cfgCache['jv5_use_ed25519_admin'];
        toastHTML(`${SVG_CHECK} Crypto settings saved`);
      } catch (e) {
        toastHTML(`${SVG_CROSS} Failed to save crypto settings`);
      }
    });

    // ── PSK backup / restore wiring ─────────────────────────────────────────
    // Show a fingerprint (first 12 chars) of the current key so the user can
    // confirm the right key is active after restoring.
    const _pskFingerprintEl = panel.querySelector('#ms2-psk-fingerprint');
    (async () => {
      try {
        const currentKey = GM_getValue(_pskGmKey, null);
        if (currentKey && _pskFingerprintEl) {
          // Show SHA-256 prefix as a human-readable fingerprint
          const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(currentKey));
          const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
          _pskFingerprintEl.textContent = `Active key fingerprint: ${hashHex.slice(0, 24)}…`;
        }
      } catch {}
    })();

    panel.querySelector('#ms2-psk-backup-btn')?.addEventListener('click', () => {
      try {
        const key = GM_getValue(_pskGmKey, null);
        if (!key) { toastHTML(`${SVG_WARNING} No identity key found`); return; }
        const payload = JSON.stringify({ jv5_psk_b: key, exported: new Date().toISOString() });
        const blob = new Blob([payload], { type: 'application/json' });
        const url  = URL.createObjectURL(blob);
        const a    = document.createElement('a');
        a.href = url;
        a.download = `jv5-chat-identity-${new Date().toISOString().slice(0,10)}.json`;
        a.click();
        URL.revokeObjectURL(url);
        toastHTML(`${SVG_CHECK} Chat identity key exported — store this file somewhere safe`);
      } catch (e) {
        toastHTML(`${SVG_CROSS} Export failed: ${escHtml(e.message)}`);
      }
    });

    panel.querySelector('#ms2-psk-copy-btn')?.addEventListener('click', () => {
      try {
        const key = GM_getValue(_pskGmKey, null);
        if (!key) { toastHTML(`${SVG_WARNING} No identity key found`); return; }
        navigator.clipboard.writeText(key)
          .then(() => toastHTML(`${SVG_CHECK} Key copied to clipboard`))
          .catch(() => toastHTML(`${SVG_WARNING} Clipboard blocked — use Export instead`));
      } catch (e) {
        toastHTML(`${SVG_CROSS} Copy failed: ${escHtml(e.message)}`);
      }
    });

    panel.querySelector('#ms2-psk-restore-btn')?.addEventListener('click', async () => {
      try {
        const input = panel.querySelector('#ms2-psk-restore-input');
        if (!input) return;
        let raw = input.value.trim();
        if (!raw) { toastHTML(`${SVG_WARNING} Paste your exported key first`); return; }

        // Accept either raw hex string or the full JSON export object
        if (raw.startsWith('{')) {
          try {
            const parsed = JSON.parse(raw);
            if (parsed && typeof parsed.jv5_psk_b === 'string') raw = parsed.jv5_psk_b.trim();
          } catch {}
        }

        // Validate: must be a 64-char hex string (32 bytes)
        if (!/^[0-9a-f]{64}$/i.test(raw)) {
          toastHTML(`${SVG_WARNING} Invalid key — expected 64 hex characters`);
          return;
        }

        GM_setValue(_pskGmKey, raw);
        input.value = '';

        // Update fingerprint display
        if (_pskFingerprintEl) {
          const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
          const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
          _pskFingerprintEl.textContent = `Active key fingerprint: ${hashHex.slice(0, 24)}…`;
        }

        toastHTML(`${SVG_CHECK} Identity key restored — <strong>reload the page</strong> to apply`);
      } catch (e) {
        toastHTML(`${SVG_CROSS} Restore failed: ${escHtml(e.message)}`);
      }
    });

    // ── Custom passphrase UI wiring ─────────────────────────────────────────
    const pskInput    = panel.querySelector('#ms2-s-custom-psk');
    const pskToggle   = panel.querySelector('#ms2-psk-toggle');
    const pskBar      = panel.querySelector('#ms2-psk-strength-bar');
    const pskLabel    = panel.querySelector('#ms2-psk-strength-label');
    const pskSaveBtn  = panel.querySelector('#ms2-save-custom-psk');
    const pskClearBtn = panel.querySelector('#ms2-clear-custom-psk');

    // Show/hide toggle
    pskToggle?.addEventListener('click', () => {
      if (!pskInput) return;
      const isHidden = pskInput.type === 'password';
      pskInput.type = isHidden ? 'text' : 'password';
      pskToggle.textContent = isHidden ? '🙈' : '👁';
      // If it was showing placeholder dots from a saved passphrase, clear them so user can type
      if (pskInput.value === '••••••••') pskInput.value = '';
    });

    // Strength meter — runs on every keystroke
    const _pskStrength = (pw) => {
      if (!pw) return { score: 0, label: '', color: '#ef4444' };
      let score = 0;
      if (pw.length >= 8)  score++;
      if (pw.length >= 14) score++;
      if (pw.length >= 20) score++;
      if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) score++;
      if (/[0-9]/.test(pw)) score++;
      if (/[^A-Za-z0-9]/.test(pw)) score++;
      const tiers = [
        { min: 0, label: '',          color: '#ef4444', pct: 0   },
        { min: 1, label: 'Weak',      color: '#ef4444', pct: 20  },
        { min: 2, label: 'Fair',      color: '#f59e0b', pct: 40  },
        { min: 3, label: 'Moderate',  color: '#eab308', pct: 60  },
        { min: 4, label: 'Strong',    color: '#22c55e', pct: 80  },
        { min: 6, label: 'Very strong', color: '#10b981', pct: 100 },
      ];
      const tier = [...tiers].reverse().find(t => score >= t.min) || tiers[0];
      return { score, label: tier.label, color: tier.color, pct: tier.pct };
    };

    pskInput?.addEventListener('input', () => {
      const val = pskInput.value;
      // If user started typing while dots were shown, treat as fresh input
      if (!pskBar || !pskLabel) return;
      const { label, color, pct } = _pskStrength(val);
      pskBar.style.width   = pct + '%';
      pskBar.style.background = color;
      pskLabel.textContent = val ? label : '';
      pskLabel.style.color = color;
    });

    // Set passphrase — hashes before storing, never stores raw string
    pskSaveBtn?.addEventListener('click', async () => {
      const raw = (pskInput?.value || '').trim();
      if (!raw || raw === '••••••••') {
        toastHTML(`${SVG_WARNING} Enter a passphrase first`, 2500);
        return;
      }
      if (raw.length < 8) {
        toastHTML(`${SVG_WARNING} Passphrase must be at least 8 characters`, 2500);
        return;
      }
      try {
        // Store a SHA-256 hash of the passphrase — never the raw string.
        // The hash is used as input to PBKDF2 in _deriveKey, so brute-forcing
        // the stored hash doesn't directly give the AES key.
        const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw + ':jv5-psk-user'));
        const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
        GM_setValue('jv5_custom_psk_hash', hashHex);
        GM_setValue('jv5_custom_psk_set', true);
        // Invalidate key cache and ratchet state — all sessions must re-derive with new PSK
        P2PCrypto.clearKeyCache();
        P2PCrypto.clearRatchetState();
        // Force disconnect/reconnect if chat is open so peers re-handshake
        if (chatStore.open) {
          chatNet.disconnect();
          setTimeout(() => chatNet.connect().catch(() => {}), 800);
        }
        pskInput.value = '••••••••';
        pskInput.type  = 'password';
        if (pskBar)  { pskBar.style.width = '0%'; }
        if (pskLabel){ pskLabel.textContent = ''; }
        toastHTML(`${SVG_CHECK} Custom passphrase set — only users with the same passphrase can read your messages`);
      } catch (e) {
        toastHTML(`${SVG_CROSS} Failed to save passphrase`);
      }
    });

    // Clear passphrase — reverts to default GM-split PSK
    pskClearBtn?.addEventListener('click', () => {
      GM_setValue('jv5_custom_psk_hash', '');
      GM_setValue('jv5_custom_psk_set', false);
      P2PCrypto.clearKeyCache();
      P2PCrypto.clearRatchetState();
      if (chatStore.open) {
        chatNet.disconnect();
        setTimeout(() => chatNet.connect().catch(() => {}), 800);
      }
      if (pskInput)  { pskInput.value = ''; pskInput.type = 'password'; }
      if (pskBar)    { pskBar.style.width = '0%'; }
      if (pskLabel)  { pskLabel.textContent = ''; }
      toastHTML(`${SVG_CHECK} Passphrase cleared — using default shared PSK`);
    });

    panel.querySelector('#ms2-save-reply').addEventListener('click', () => {
      CFG.defaultTone     = panel.querySelector('#ms2-s-default-tone').value;
      CFG.defaultInstruct = panel.querySelector('#ms2-s-default-instruct').value.trim();
      CFG.autoNotify      = panel.querySelector('#ms2-s-autonotify').checked;
      if (CFG.autoNotify && isOnChatPage()) startObserver();
      else if (!CFG.autoNotify) stopObserver();
      toastHTML(`${SVG_CHECK} Reply settings saved`);
      closeAll();
    });

    panel.querySelector('#ms2-s-context').addEventListener('input', e => {
      const counter = panel.querySelector('#ms2-ctx-count');
      if (!counter) return;
      const len = e.target.value.length;
      counter.textContent = `${len} / 2000`;
      counter.style.color = len >= 1800 ? '#f87171' : len >= 1500 ? '#fb923c' : '#6b7280';
    });

    panel.querySelector('#ms2-save-context').addEventListener('click', () => {
      saveContext(panel.querySelector('#ms2-s-context').value);
      toastHTML(`${SVG_CHECK} Context saved`);
    });

    panel.querySelector('#ms2-ctx-inject-chk')?.addEventListener('change', e => {
      setInjectCtx(e.target.checked);
      toast(e.target.checked
        ? `${SVG_CHECK} Scene Context will now be sent to JanitorAI on every generation`
        : `${SVG_CROSS} Scene Context injection disabled`, 3000);
    });

    const personaList    = panel.querySelector('#ms2-persona-list');
    const personaForm    = panel.querySelector('#ms2-persona-form');
    const personaAddBtn  = panel.querySelector('#ms2-persona-add-btn');
    const personaNameIn  = panel.querySelector('#ms2-persona-name-input');
    const personaDescIn  = panel.querySelector('#ms2-persona-desc-input');
    const personaSaveBtn = panel.querySelector('#ms2-persona-save-btn');
    const personaCancelBtn = panel.querySelector('#ms2-persona-cancel-btn');
    let _editingPersonaId = null; 

    function renderPersonaList() {
      if (!personaList) return;
      const personas = getPersonaLib();
      if (!personas.length) {
        personaList.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:4px 2px;">No personas saved yet. Click + Add to create one.</div>';
        return;
      }
      
      personaList.innerHTML = personas.map(p => `
        <div class="ms2-pl-card">
          <div style="flex:1;min-width:0;">
            <div style="font-size:12px;color:#c4b5fd;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(p.name)}</div>
            <div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">${escHtml(p.desc)}</div>
          </div>
          <div style="display:flex;flex-direction:column;gap:3px;flex-shrink:0;">
            <button data-pid="${escHtml(p.id)}" class="pl-use" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:2px 6px;">Use</button>
            <button data-pid="${escHtml(p.id)}" class="pl-edit" style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:4px;color:#a78bfa;font-size:10px;cursor:pointer;padding:2px 6px;">Edit</button>
            <button data-pid="${escHtml(p.id)}" class="pl-del" style="background:none;border:1px solid rgba(255,255,255,0.08);border-radius:4px;color:#6b7280;font-size:10px;cursor:pointer;padding:2px 6px;">${SVG_TRASH}</button>
          </div>
        </div>`).join('');
    }

    function openPersonaForm(editId = null) {
      _editingPersonaId = editId;
      if (!editId) {
        personaNameIn.value = '';
        personaDescIn.value = '';
        personaSaveBtn.textContent = 'Save';
      }
      personaForm.style.display = '';
      personaNameIn.focus();
    }

    function closePersonaForm() {
      personaForm.style.display = 'none';
      _editingPersonaId = null;
      personaNameIn.value = '';
      personaDescIn.value = '';
      personaSaveBtn.textContent = 'Save';
    }

    personaAddBtn?.addEventListener('click', () => openPersonaForm(null));
    personaCancelBtn?.addEventListener('click', closePersonaForm);

    personaSaveBtn?.addEventListener('click', () => {
      const name = personaNameIn.value.trim();
      const desc = personaDescIn.value.trim();
      if (!name) { toastHTML(`${SVG_WARNING} Enter a name for this persona`); personaNameIn.focus(); return; }
      if (!desc)  { toastHTML(`${SVG_WARNING} Enter a description for this persona`); personaDescIn.focus(); return; }
      const arr = getPersonaLib();
      if (_editingPersonaId) {
        const idx = arr.findIndex(x => x.id === _editingPersonaId);
        if (idx !== -1) arr[idx] = { ...arr[idx], name, desc };
      } else {
        arr.push({ id: 'pl_' + Date.now().toString(36), name, desc });
      }
      savePersonaLib(arr);
      closePersonaForm();
      renderPersonaList();
      toastHTML(`${SVG_CHECK} Persona ${_editingPersonaId ? 'updated' : 'saved'}`);
    });

    personaDescIn?.addEventListener('keydown', e => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); personaSaveBtn.click(); }
    });

    personaList?.addEventListener('click', e => {
      const btn = e.target.closest('button[data-pid]');
      if (!btn) return;
      const pid = btn.dataset.pid;
      const p   = getPersonaLib().find(x => x.id === pid);
      if (!p) return;
      if (btn.classList.contains('pl-use')) {
        const ta = panel.querySelector('#ms2-s-context');
        if (ta) { ta.value = p.desc; ta.dispatchEvent(new Event('input', { bubbles: true })); }
        toastHTML(`${SVG_PERSONA} Persona loaded into Scene Context`);
      } else if (btn.classList.contains('pl-edit')) {
        _editingPersonaId = p.id;
        personaNameIn.value = p.name;
        personaDescIn.value = p.desc;
        personaForm.style.display = '';
        personaSaveBtn.textContent = 'Update';
        personaNameIn.focus();
        personaForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      } else if (btn.classList.contains('pl-del')) {
        savePersonaLib(getPersonaLib().filter(x => x.id !== p.id));
        renderPersonaList();
        toastHTML(`${SVG_TRASH} Persona removed`);
      }
    });

    renderPersonaList();

    panel.querySelector('#ms2-persona-export-btn')?.addEventListener('click', () => {
      const arr = getPersonaLib();
      if (!arr.length) { toastHTML(`${SVG_WARNING} No personas to export yet.`); return; }
      const json = JSON.stringify({ version: 1, personas: arr }, null, 2);
      const blob = new Blob([json], { type: 'application/json' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = 'JanitorV5-personas.json';
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
      toastHTML(`${SVG_SAVE} Exported ${arr.length} persona${arr.length !== 1 ? 's' : ''}`);
    });

    const importFileInput = panel.querySelector('#ms2-persona-import-file');

    panel.querySelector('#ms2-persona-import-btn')?.addEventListener('click', () => {
      importFileInput?.click();
    });

    importFileInput?.addEventListener('change', () => {
      const file = importFileInput.files?.[0];
      if (!file) return;
      importFileInput.value = ''; 
      const reader = new FileReader();
      reader.onload = ev => {
        try {
          const parsed = JSON.parse(ev.target.result);
          
          const incoming = Array.isArray(parsed)
            ? parsed
            : (Array.isArray(parsed?.personas) ? parsed.personas : null);
          if (!incoming) { toastHTML(`${SVG_WARNING} Invalid file — expected a personas JSON export.`); return; }
          const valid = incoming.filter(p =>
            p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.desc === 'string'
          );
          if (!valid.length) { toastHTML(`${SVG_WARNING} No valid persona entries found in file.`); return; }
          
          const existing = getPersonaLib();
          const existingIds = new Set(existing.map(x => x.id));
          const merged = [...existing, ...valid.filter(p => !existingIds.has(p.id))];
          savePersonaLib(merged);
          renderPersonaList();
          const added = merged.length - existing.length;
          toastHTML(`${SVG_FOLDER} Imported ${added} new persona${added !== 1 ? 's' : ''}` +
            (added < valid.length ? ' (' + (valid.length - added) + ' already existed)' : ''));
        } catch {
          toastHTML(`${SVG_WARNING} Could not read file — make sure it is a valid JSON export.`);
        }
      };
      reader.readAsText(file);
    });

    panel.querySelectorAll('[data-tab="context"]').forEach(btn =>
      btn.addEventListener('click', () => { renderSumHistory(); })
    );
    
    (() => { renderSumHistory(); })();

    panel.querySelector('#ms2-ctx-gen-btn')?.addEventListener('click', () => {
      doGenerateSummary({ silent: false });
    });

    panel.querySelector('#ms2-ctx-save-global-btn')?.addEventListener('click', () => {
      const text = panel.querySelector('#ms2-s-context')?.value || getContext();
      if (!text.trim()) { toastHTML(`${SVG_WARNING} No context text to save`); return; }
      saveGlobalMemory(text.trim());
      toastHTML(`${SVG_SAVE} Context saved as global memory`);
    });

    panel.querySelector('#ms2-ctx-load-global-btn')?.addEventListener('click', () => {
      const mem = getGlobalMemory().trim();
      if (!mem) { toastHTML(`${SVG_WARNING} No global memory saved yet`); return; }
      const ta = panel.querySelector('#ms2-s-context');
      if (!ta) return;
      ta.value = mem;
      ta.dispatchEvent(new Event('input', { bubbles: true }));
      saveContext(mem);
      toastHTML(`${SVG_FOLDER} Global memory loaded into this chat`);
    });

    const _charId = getCurrentCharId();
    const charRow = panel.querySelector('#ms2-ctx-char-row');
    if (!_charId && charRow) charRow.style.display = 'none';
    panel.querySelector('#ms2-ctx-save-char-btn')?.addEventListener('click', () => {
      if (!_charId) { toastHTML(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
      const text = panel.querySelector('#ms2-s-context')?.value || getContext();
      if (!text.trim()) { toastHTML(`${SVG_WARNING} No context text to save`); return; }
      saveCharGlobalMemory(_charId, text.trim());
      toastHTML(`${SVG_MEMORY} Memory saved for this character`);
    });
    panel.querySelector('#ms2-ctx-load-char-btn')?.addEventListener('click', () => {
      if (!_charId) { toastHTML(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
      const mem = getCharGlobalMemory(_charId).trim() || getGlobalMemory().trim();
      if (!mem) { toastHTML(`${SVG_WARNING} No saved memory for this character yet`); return; }
      const ta = panel.querySelector('#ms2-s-context');
      if (!ta) return;
      ta.value = mem;
      ta.dispatchEvent(new Event('input', { bubbles: true }));
      saveContext(mem);
      toastHTML(`${SVG_MEMORY} Character memory loaded`);
    });

    panel.querySelector('#ms2-ctx-autoload-save')?.addEventListener('click', () => {
      setAutoLoadGlobal(panel.querySelector('#ms2-ctx-autoload-chk')?.checked || false);
      toastHTML(`${SVG_CHECK} Auto-load preference saved`);
    });

    panel.querySelector('#ms2-ctx-auto-save')?.addEventListener('click', () => {
      const every  = parseInt(panel.querySelector('#ms2-ctx-auto-every')?.value) || 0;
      const auto   = panel.querySelector('#ms2-ctx-auto-chk')?.checked || false;
      setAutoSumEvery(every);
      setAutoSumAuto(auto);
      stopAutoSumObserver();
      if (every > 0) startAutoSumObserver();
      toast(every > 0
        ? `${SVG_CHECK} Auto-context: every ${every} messages${auto ? ', generates automatically' : ', notifies only'}`
        : `${SVG_CHECK} Auto-context disabled`);
    });

    panel.querySelector('#ms2-ctx-export-hist')?.addEventListener('click', () => {
      const hist = getSumHistory();
      if (!hist.length) { toast('No summaries to export yet'); return; }
      const blob = new Blob([JSON.stringify(hist, null, 2)], { type: 'application/json' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = `JanitorV5-summaries-${new Date().toISOString().slice(0,10)}.json`;
      a.click();
      URL.revokeObjectURL(url);
      toastHTML(`${SVG_SAVE} Exported ${hist.length} summar${hist.length !== 1 ? 'ies' : 'y'}`);
    });

    panel.querySelector('#ms2-ctx-clear-hist')?.addEventListener('click', function() {
      const clearBtn = this;
      showInlineConfirm(panel, {
        message: 'Clear all saved summaries?',
        insertBefore: clearBtn,
        onConfirm: () => {
          saveSumHistory([]);
          renderSumHistory();
          toast('History cleared');
        },
      });
    });

    renderPresets(panel);

    function showTabHelp(title, bodyHtml) {
      document.getElementById('tab-help-backdrop')?.remove();
      const backdrop = document.createElement('div');
      backdrop.className = 'ms2-backdrop';
      backdrop.id = 'tab-help-backdrop';
      backdrop.style.zIndex = '10000020';
      const modal = document.createElement('div');
      modal.className = 'ms2-modal';
      modal.style.maxWidth = '520px';
      modal.setAttribute('role', 'dialog');
      modal.innerHTML = `
        <div class="ms2-modal-header">
          <div class="ms2-modal-title">${title}</div>
          <button class="ms2-modal-close" id="tab-help-close">×</button>
        </div>
        <div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">${bodyHtml}</div>`;
      backdrop.appendChild(modal);
      document.body.appendChild(backdrop);
      const close = () => backdrop.remove();
      modal.querySelector('#tab-help-close').addEventListener('click', close);
      setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
      addEscapeClose(backdrop);
    }


    apRenderPanel(panel);
  }

  // ─── ADV. PROMPT PANEL ────────────────────────────────────────────────────

  function apRenderPanel(panel) {
    const presetSel     = panel.querySelector('#ap-preset-sel');
    const modulesWrap   = panel.querySelector('#ap-modules-wrap');
    const noPresetMsg   = panel.querySelector('#ap-no-preset-msg');
    const moduleList    = panel.querySelector('#ap-module-list');
    const unattachedSel = panel.querySelector('#ap-unattached-sel');
    if (!presetSel) return;


    panel.querySelector('#ap-enabled-chk').addEventListener('change', e => {
      AP.enabled = e.target.checked;
    });

    const thinkingChk = panel.querySelector('#ap-thinking-chk');
    if (thinkingChk) {
      thinkingChk.checked = getAPThinking();
      thinkingChk.addEventListener('change', e => setAPThinking(e.target.checked));
    }

    const forbiddenInput  = panel.querySelector('#ap-forbidden-input');
    const forbiddenAddBtn = panel.querySelector('#ap-forbidden-add-btn');
    const forbiddenTagsEl = panel.querySelector('#ap-forbidden-tags');

    if (forbiddenInput && forbiddenAddBtn && forbiddenTagsEl) {
      let forbiddenWords = getAPForbiddenWords().trim().split('\n').filter(Boolean);

      function renderCounter() {
        const counterEl = panel.querySelector('#ap-forbidden-counter');
        if (!counterEl) return;
        const scriptCount = forbiddenWords.length;
        if (scriptCount === 0) {
          counterEl.style.display = 'none';
          return;
        }
        const nativeCount = parseInt(gget('ap_native_ban_count', '-1'));
        counterEl.style.display = 'block';
        if (nativeCount >= 0) {
          counterEl.innerHTML =
            `⚡ <strong>${nativeCount + scriptCount}</strong> words active this session` +
            `&nbsp;<span style="color:#6b7280">(${nativeCount} native + ${scriptCount} from script)</span>`;
        } else {
          counterEl.innerHTML =
            `⚡ <strong>${scriptCount}</strong> extra word${scriptCount !== 1 ? 's' : ''} queued — ` +
            `<span style="color:#6b7280">send a message to see the full count</span>`;
        }
      }

      function renderForbiddenTags() {
        forbiddenTagsEl.innerHTML = '';
        if (!forbiddenWords.length) {
          forbiddenTagsEl.innerHTML = '<span style="font-size:11px;color:#4b5563;padding:2px 4px;">No banned words yet — add one above.</span>';
          renderCounter();
          return;
        }
        forbiddenWords.forEach((word, idx) => {
          const tag = document.createElement('span');
          tag.style.cssText =
            'display:inline-flex;align-items:center;gap:3px;' +
            'background:rgba(139,92,246,0.15);color:#c4b5fd;' +
            'padding:2px 7px;border-radius:4px;font-size:11px;' +
            'border:1px solid rgba(139,92,246,0.3);';
          tag.innerHTML =
            `<span>${escHtml(word)}</span>` +
            `<button data-idx="${idx}" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:10px;line-height:1;padding:0 0 0 2px;">×</button>`;
          tag.querySelector('button').addEventListener('click', () => {
            forbiddenWords.splice(idx, 1);
            setAPForbiddenWords(forbiddenWords.join('\n'));
            renderForbiddenTags();
          });
          forbiddenTagsEl.appendChild(tag);
        });
        renderCounter();
      }

      function addForbiddenWord() {
        const w = forbiddenInput.value.trim();
        if (!w) return;
        if (!forbiddenWords.includes(w)) {
          forbiddenWords.push(w);
          setAPForbiddenWords(forbiddenWords.join('\n'));
          renderForbiddenTags();
        }
        forbiddenInput.value = '';
        forbiddenInput.focus();
      }

      forbiddenAddBtn.addEventListener('click', addForbiddenWord);
      forbiddenInput.addEventListener('keydown', e => {
        if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord(); }
      });

      renderForbiddenTags();
    }

    function fillPresetSel() {
      const presets = AP.getPresets();
      presetSel.innerHTML = `<option value="">— Select preset —</option>` +
        presets.map(p => `<option value="${escHtml(p.id)}" ${p.id === AP.selected ? 'selected' : ''}>${escHtml(p.name)}</option>`).join('');
    }

    function refresh() {
      fillPresetSel();
      const preset = apGetSelected();
      const hasPreset = !!preset;
      modulesWrap.style.display  = hasPreset ? '' : 'none';
      noPresetMsg.style.display  = hasPreset ? 'none' : '';
      if (hasPreset) {
        renderModuleList();
        fillUnattachedSel();
        updateTokenBar();
      }
      apRefreshSaveBtn();
    }

    function updateTokenBar() {
      const combined = apGetCombinedPrompt();
      const tokens   = apEstimateTokens(combined || '');
      const MAX_DISP = 4096;
      const pct      = Math.min(100, (tokens / MAX_DISP) * 100);
      const fill     = panel.querySelector('#ap-token-fill');
      const label    = panel.querySelector('#ap-token-count');
      if (fill)  fill.style.width = pct + '%';
      if (label) label.textContent = `~${tokens} tokens`;
    }

    function renderModuleList() {
      const preset = apGetSelected();
      if (!preset) { moduleList.innerHTML = ''; return; }

      const attached = (preset.modules || [])
        .filter(m => m.attached !== false)
        .sort((a, b) => a.order - b.order);

      if (!attached.length) {
        moduleList.innerHTML = '<div class="ap-empty">No attached modules. Add one below.</div>';
        return;
      }

      moduleList.innerHTML = '';
      attached.forEach(mod => {
        const item = document.createElement('div');
        item.className = 'ap-module-item' + (mod.enabled ? '' : ' ap-disabled');
        item.draggable = true;
        item.dataset.mid = mod.id;
        item.innerHTML = `
          <span class="ap-drag-handle" title="Drag to reorder">⠿</span>
          <span class="ap-module-name" title="${escHtml(mod.name)}">${escHtml(mod.name)}</span>
          <div class="ap-module-btns">
            <button class="ap-module-btn" data-act="edit" title="Edit content">✎</button>
            <button class="ap-module-btn ap-del" data-act="unattach" title="Unattach">⊟</button>
            <label class="ap-module-switch" title="${mod.enabled ? 'Disable' : 'Enable'}">
              <input type="checkbox" ${mod.enabled ? 'checked' : ''}>
              <span class="ap-module-thumb"></span>
            </label>
          </div>`;

        item.querySelector('input[type=checkbox]').addEventListener('change', e => {
          const p = apGetSelected();
          const m = p.modules.find(x => x.id === mod.id);
          if (m) { m.enabled = e.target.checked; apMarkDirty(); renderModuleList(); updateTokenBar(); }
        });

        item.querySelectorAll('[data-act]').forEach(btn => {
          btn.addEventListener('click', e => {
            e.stopPropagation();
            if (btn.dataset.act === 'edit') apOpenModuleEditor(mod.id, refresh);
            if (btn.dataset.act === 'unattach') {
              const p = apGetSelected();
              const m = p.modules.find(x => x.id === mod.id);
              if (m) { m.attached = false; m.enabled = false; apMarkDirty(); refresh(); }
            }
          });
        });

        item.addEventListener('dragstart', e => {
          e.dataTransfer.effectAllowed = 'move';
          item.classList.add('ap-dragging');
        });
        item.addEventListener('dragend', e => {
          item.classList.remove('ap-dragging');
          moduleList.querySelectorAll('.ap-module-item').forEach(i => i.classList.remove('ap-drag-over'));
          
          if (e.dataTransfer.dropEffect === 'none') return;
          
          const p = apGetSelected();
          if (p) {
            [...moduleList.querySelectorAll('.ap-module-item')].forEach((el, idx) => {
              const m = p.modules.find(x => x.id === el.dataset.mid);
              if (m) m.order = idx;
            });
            apMarkDirty();
          }
        });
        item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('ap-drag-over'); });
        item.addEventListener('dragleave', () => item.classList.remove('ap-drag-over'));
        item.addEventListener('drop', e => {
          e.preventDefault();
          item.classList.remove('ap-drag-over');
          const dragging = moduleList.querySelector('.ap-dragging');
          if (dragging && dragging !== item) {
            const all = [...moduleList.children];
            dragging.parentNode.insertBefore(
              dragging,
              all.indexOf(dragging) < all.indexOf(item) ? item.nextSibling : item
            );
          }
        });

        moduleList.appendChild(item);
      });
    }

    function fillUnattachedSel() {
      const preset = apGetSelected();
      if (!preset) { unattachedSel.innerHTML = ''; return; }
      const unattached = (preset.modules || []).filter(m => !m.attached);
      unattachedSel.innerHTML = unattached.length
        ? `<option value="">Select module…</option>` + unattached.map(m => `<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`).join('')
        : `<option value="">No unattached modules</option>`;
    }

    presetSel.addEventListener('change', () => {
      if (_apDirty) {
        const incoming = presetSel.value;
        presetSel.value = AP.selected; 
        showInlineConfirm(panel, {
          message: 'Unsaved changes — discard them?',
          insertBefore: presetSel.closest('.ap-row') || presetSel.parentElement,
          onConfirm: () => {
            AP.selected = incoming;
            presetSel.value = incoming;
            apLoadFromStorage();
            refresh();
          },
        });
        return;
      }
      AP.selected = presetSel.value;
      apLoadFromStorage();
      refresh();
    });

    panel.querySelector('#ap-new-preset-btn').addEventListener('click', () => {
      showInlineNameInput(panel, {
        placeholder: 'New preset name…',
        initialValue: '',
        insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
        onConfirm: (name) => {
          const presets = AP.getPresets();
          let n = name, c = 1;
          while (presets.some(p => p.name === n)) n = `${name} (${c++})`;
          const np = { id: apUUID(), name: n, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modules: [] };
          presets.push(np);
          AP.savePresets(presets);
          AP.selected = np.id;
          apLoadFromStorage();
          refresh();
        },
      });
    });

    panel.querySelector('#ap-rename-preset-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      showInlineNameInput(panel, {
        placeholder: 'Preset name…',
        initialValue: p.name,
        insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
        onConfirm: (name) => {
          if (name === p.name) return;
          p.name = name;
          apMarkDirty();
          apSaveWorking();
          refresh();
        },
      });
    });

    panel.querySelector('#ap-export-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
      const a = Object.assign(document.createElement('a'), {
        href: URL.createObjectURL(blob),
        download: p.name.replace(/[^a-z0-9]/gi, '_') + '_preset.json',
      });
      document.body.appendChild(a); a.click();
      
      setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 1000);
    });

    panel.querySelector('#ap-import-btn').addEventListener('click', () => {
      const inp = Object.assign(document.createElement('input'), { type: 'file', accept: '.json' });
      inp.addEventListener('change', async () => {
        try {
          const data = JSON.parse(await inp.files[0].text());
          if (!data.name || !Array.isArray(data.modules)) { toast('Invalid preset file'); return; }
          const presets = AP.getPresets();
          let n = data.name, c = 1;
          while (presets.some(p => p.name === n)) n = `${data.name} (${c++})`;
          data.name = n;
          data.id   = apUUID();
          data.createdAt = data.updatedAt = new Date().toISOString();
          data.modules.forEach(m => { if (typeof m.attached !== 'boolean') m.attached = true; });
          presets.push(data);
          AP.savePresets(presets);
          AP.selected = data.id;
          apLoadFromStorage();
          refresh();
          toastHTML(`${SVG_CHECK} Preset imported`);
        } catch(e) { toast('Import failed: ' + e.message); }
      });
      inp.click();
    });

    panel.querySelector('#ap-delete-preset-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const delBtn = panel.querySelector('#ap-delete-preset-btn');
      showInlineConfirm(panel, {
        message: `Delete "${p.name}"?`,
        insertBefore: delBtn,
        onConfirm: () => {
          AP.savePresets(AP.getPresets().filter(x => x.id !== p.id));
          AP.selected = '';
          _apWorking  = null;
          _apDirty    = false;
          toast('Preset deleted');
          refresh();
        },
      });
    });

    panel.querySelector('#ap-attach-btn').addEventListener('click', () => {
      const p  = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      const id = unattachedSel.value;
      
      if (!id) {
        showInlineNameInput(panel, {
          placeholder: 'Module name…',
          initialValue: '',
          insertAnchor: panel.querySelector('#ap-attach-btn'),
          onConfirm: (name) => {
            const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
            p.modules.push(mod);
            apMarkDirty();
            apOpenModuleEditor(mod.id, refresh);
            refresh();
          },
        });
        return;
      }
      const m = p.modules.find(x => x.id === id);
      if (m) {
        m.attached = true;
        m.enabled  = true;
        m.order    = p.modules.filter(x => x.attached).length - 1;
        apMarkDirty(); refresh();
      }
    });

    panel.querySelector('#ap-del-module-btn').addEventListener('click', () => {
      const p  = apGetSelected();
      const id = unattachedSel.value;
      if (!p || !id) return;
      const m = p.modules.find(x => x.id === id);
      if (!m) return;
      const delModBtn = panel.querySelector('#ap-del-module-btn');
      showInlineConfirm(panel, {
        message: `Delete module "${m.name}"?`,
        insertBefore: delModBtn,
        onConfirm: () => {
          p.modules = p.modules.filter(x => x.id !== id);
          apMarkDirty(); refresh();
        },
      });
    });

    panel.querySelector('#ap-new-module-btn').addEventListener('click', () => {
      const p = apGetSelected();
      if (!p) { toast('Select a preset first'); return; }
      
      showInlineNameInput(panel, {
        placeholder: 'Module name…',
        initialValue: '',
        insertAnchor: panel.querySelector('#ap-new-module-btn'),
        onConfirm: (name) => {
          const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
          p.modules.push(mod);
          apMarkDirty();
          apOpenModuleEditor(mod.id, refresh);
          refresh();
        },
      });
    });

    panel.querySelector('#ap-save-btn').addEventListener('click', () => {
      apSaveWorking(); toastHTML(`${SVG_CHECK} Preset saved`); refresh();
    });
    panel.querySelector('#ap-discard-btn').addEventListener('click', () => {
      apLoadFromStorage(); refresh();
    });

    refresh();
  }

  // ─── ADV. PROMPT — MODULE EDITOR MODAL ────────────────────────────────────

  function apOpenModuleEditor(moduleId, onSave) {
    const preset = apGetSelected();
    if (!preset) return;
    const mod = preset.modules.find(m => m.id === moduleId);
    if (!mod) return;

    document.getElementById('ap-editor-backdrop')?.remove();

    const backdrop = document.createElement('div');
    backdrop.className = 'ms2-backdrop';
    backdrop.id = 'ap-editor-backdrop';
    
    backdrop.style.zIndex = '10000010';

    const modal = document.createElement('div');
    modal.className = 'ms2-modal';
    modal.style.maxWidth = '640px';
    modal.setAttribute('role', 'dialog');
    modal.innerHTML = `
      <div class="ms2-modal-header">
        <div class="ms2-modal-title">✎ Edit Module</div>
        <button class="ms2-modal-close" id="ap-editor-close">×</button>
      </div>
      <div class="ms2-modal-body">
        <label class="ms2-field-label">Module Name</label>
        <input type="text" class="ms2-input" id="ap-mod-name" value="${escHtml(mod.name)}" style="margin-bottom:12px;">
        <label class="ms2-field-label">Content
          <span id="ap-mod-tokens" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;margin-left:6px;"></span>
        </label>
        <textarea class="ms2-input ms2-textarea-lg" id="ap-mod-content" style="min-height:240px;font-family:monospace;font-size:12px;">${escHtml(mod.content)}</textarea>
      </div>
      <div class="ms2-modal-footer">
        <button class="ms2-btn-action ms2-btn-generate" id="ap-editor-apply">Apply</button>
        <button class="ms2-btn-action ms2-btn-retry" id="ap-editor-cancel">Cancel</button>
      </div>`;

    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const nameInp    = modal.querySelector('#ap-mod-name');
    const contentTA  = modal.querySelector('#ap-mod-content');
    const tokenLabel = modal.querySelector('#ap-mod-tokens');

    function updateEditorTokens() {
      tokenLabel.textContent = `~${apEstimateTokens(contentTA.value)} tokens`;
    }
    contentTA.addEventListener('input', updateEditorTokens);
    updateEditorTokens();

    const close = () => backdrop.remove();
    modal.querySelector('#ap-editor-close').addEventListener('click', close);
    modal.querySelector('#ap-editor-cancel').addEventListener('click', close);
    setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
    addEscapeClose(backdrop);

    modal.querySelector('#ap-editor-apply').addEventListener('click', () => {
      mod.name    = nameInp.value.trim() || mod.name;
      mod.content = contentTA.value;
      apMarkDirty();
      if (onSave) onSave();
      close();
    });
  }

  function showInlineConfirm(panel, { message, onConfirm, insertBefore }) {
    panel.querySelector('#ap-inline-confirm-wrap')?.remove();
    const wrap = document.createElement('div');
    wrap.id = 'ap-inline-confirm-wrap';
    wrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:12px;';
    const label = document.createElement('span');
    label.style.cssText = 'flex:1;color:#f87171;';
    label.textContent = message;
    const yesBtn = document.createElement('button');
    yesBtn.className = 'ap-icon-btn';
    yesBtn.style.color = '#f87171';
    yesBtn.innerHTML = `${SVG_CHECK} Yes`;
    const noBtn = document.createElement('button');
    noBtn.className = 'ap-icon-btn';
    noBtn.textContent = 'Cancel';
    wrap.append(label, yesBtn, noBtn);
    insertBefore.parentElement.insertBefore(wrap, insertBefore);
    yesBtn.addEventListener('click', () => { wrap.remove(); onConfirm(); });
    noBtn.addEventListener('click',  () => wrap.remove());

}
  function showInlineNameInput(panel, { placeholder, initialValue, insertAnchor, onConfirm }) {
    panel.querySelector('#ap-inline-name-wrap')?.remove();
    const wrap = document.createElement('div');
    wrap.id = 'ap-inline-name-wrap';
    wrap.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center;';
    const inp = document.createElement('input');
    inp.className = 'ms2-input';
    inp.placeholder = placeholder;
    inp.value = initialValue || '';
    inp.style.cssText = 'margin-bottom:0;flex:1;min-width:0;';
    const confirmBtn = document.createElement('button');
    confirmBtn.className = 'ap-icon-btn';
    confirmBtn.innerHTML = SVG_CHECK;
    confirmBtn.title = 'Confirm';
    const cancelBtn = document.createElement('button');
    cancelBtn.className = 'ap-icon-btn';
    cancelBtn.innerHTML = SVG_CROSS;
    cancelBtn.title = 'Cancel';
    wrap.append(inp, confirmBtn, cancelBtn);
    insertAnchor.parentElement.insertBefore(wrap, insertAnchor);
    inp.focus();
    if (initialValue) inp.select();
    const doConfirm = () => {
      const val = inp.value.trim();
      if (!val) { inp.focus(); return; }
      wrap.remove();
      onConfirm(val);
    };
    confirmBtn.addEventListener('click', doConfirm);
    cancelBtn.addEventListener('click', () => wrap.remove());
    inp.addEventListener('keydown', e => {
      if (e.key === 'Enter') doConfirm();
      if (e.key === 'Escape') { e.stopPropagation(); wrap.remove(); }
    });

  // ─── PRESETS PANEL ─────────────────────────────────────────────────────────

}
  function renderPresets(panel) {
    const listEl   = panel.querySelector('#ms2-presets-list');
    const newBtn   = panel.querySelector('#ms2-new-preset-btn');
    const editorEl = panel.querySelector('#ms2-preset-editor-wrap');
    if (!listEl) return;

    const presets  = getPresets();
    const activeId = CFG.activePreset;

    if (presets.length === 0) {
      listEl.innerHTML = '<div class="ms2-presets-empty">No presets yet. Create one to save your character\'s voice.</div>';
    } else {
      listEl.innerHTML = presets.map(p => {
        const tone = TONES.find(t => t.id === p.tone);
        const isActive = p.id === activeId;
        return `
          <div class="ms2-preset-item ${isActive ? 'is-active' : ''}">
            <div class="ms2-preset-info">
              <div class="ms2-preset-name">${isActive ? '● ' : ''}${escHtml(p.name)}</div>
              ${tone ? `<div class="ms2-preset-tone">${escHtml(tone.label)}</div>` : ''}
            </div>
            <div class="ms2-preset-actions">
              <button class="ms2-preset-btn ${isActive ? 'ms2-preset-active' : 'ms2-preset-use'}" data-pid="${escHtml(p.id)}" data-action="toggle">${isActive ? `${SVG_CHECK} Active` : 'Use'}</button>
              <button class="ms2-preset-btn ms2-preset-edit" data-pid="${escHtml(p.id)}" data-action="edit">Edit</button>
              <button class="ms2-preset-btn ms2-preset-del" data-pid="${escHtml(p.id)}" data-action="del">${SVG_CROSS}</button>
            </div>
          </div>`;
      }).join('');
    }

    listEl.querySelectorAll('[data-action]').forEach(btn => {
      btn.addEventListener('click', () => {
        const pid    = btn.dataset.pid;
        const action = btn.dataset.action;
        if (action === 'toggle') {
          CFG.activePreset = (CFG.activePreset === pid) ? null : pid;
          renderPresets(panel);
          toastHTML(CFG.activePreset === pid ? `${SVG_CHECK} Preset activated` : 'Preset deactivated');
        } else if (action === 'edit') {
          const preset = getPresets().find(p => p.id === pid);
          if (preset) showPresetEditor(panel, preset);
        } else if (action === 'del') {
          showInlineConfirm(panel, {
            message: `Delete "${getPresets().find(p => p.id === pid)?.name || 'this preset'}"?`,
            insertBefore: btn,
            onConfirm: () => {
              if (CFG.activePreset === pid) CFG.activePreset = null;
              savePresets(getPresets().filter(p => p.id !== pid));
              renderPresets(panel);
              toast('Preset deleted');
            },
          });
        }
      });
    });

    newBtn.onclick = () => {
      if (getPresets().length >= 10) { toast('Maximum 10 presets'); return; }
      showPresetEditor(panel, null);
    };

}
  function showPresetEditor(panel, existingPreset) {
    const listEl   = panel.querySelector('#ms2-presets-list');
    const newBtn   = panel.querySelector('#ms2-new-preset-btn');
    const editorEl = panel.querySelector('#ms2-preset-editor-wrap');

    const p = existingPreset || { id: apUUID(), name: '', tone: '', personaNote: '', characterContext: '' };
    const isNew = !existingPreset;

    listEl.style.display  = 'none';
    newBtn.style.display  = 'none';
    editorEl.style.display = '';

    const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
      `<option value="${escHtml(t.id)}" ${p.tone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
    ).join('');

    editorEl.innerHTML = `
      <div class="ms2-label" style="margin-bottom:10px;">${isNew ? 'New Preset' : 'Edit Preset'}</div>
      <label class="ms2-field-label">Preset Name *</label>
      <input type="text" class="ms2-input" id="ms2-pe-name" value="${escHtml(p.name)}" placeholder="e.g. Rvie's Teasing Mode">
      <label class="ms2-field-label">Default Tone</label>
      <select class="ms2-select" id="ms2-pe-tone">${toneOpts}</select>
      <label class="ms2-field-label">Your Character's Persona Note</label>
      <textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-persona" placeholder="Describe how your character talks, acts, and thinks…">${escHtml(p.personaNote || '')}</textarea>
      <label class="ms2-field-label">Other Character Context <span style="font-weight:400;">(optional)</span></label>
      <textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-charctx" placeholder="Notes about the AI character you're roleplaying with…">${escHtml(p.characterContext || '')}</textarea>
      <div class="ms2-settings-actions">
        <button class="ms2-btn-save" id="ms2-pe-save">Save Preset</button>
        <button class="ms2-btn-cancel" id="ms2-pe-cancel">Cancel</button>
      </div>`;

    editorEl.querySelector('#ms2-pe-cancel').addEventListener('click', () => {
      editorEl.style.display = 'none';
      editorEl.innerHTML = '';
      listEl.style.display  = '';
      newBtn.style.display  = '';
      renderPresets(panel);
    });

    editorEl.querySelector('#ms2-pe-save').addEventListener('click', () => {
      const name = editorEl.querySelector('#ms2-pe-name').value.trim();
      if (!name) { toast('Preset name is required'); return; }
      const updated = {
        id:               p.id,
        name,
        tone:             editorEl.querySelector('#ms2-pe-tone').value,
        personaNote:      editorEl.querySelector('#ms2-pe-persona').value.trim(),
        characterContext: editorEl.querySelector('#ms2-pe-charctx').value.trim(),
      };
      const presets = getPresets();
      const idx = presets.findIndex(x => x.id === updated.id);
      if (idx >= 0) presets[idx] = updated; else presets.push(updated);
      savePresets(presets);
      editorEl.style.display = 'none';
      editorEl.innerHTML = '';
      listEl.style.display  = '';
      newBtn.style.display  = '';
      renderPresets(panel);
      toastHTML(`${SVG_CHECK} Preset saved`);
    });
  }

  // ─── FAB (draggable, tap = speed-dial, long-press = settings) ─────────────

  const LONG_PRESS_MS = 600;

  /**
   * Creates the floating action button (FAB) and wires all its interaction
   * events if it doesn't already exist.
   *
   * Interaction model:
   *  - **Tap / click** → opens the speed-dial panel.
   *  - **Long-press (≥ 500 ms)** → opens the Settings modal.
   *  - **Drag** → repositions the FAB; final position is persisted to GM storage.
   *
   * The FAB position is clamped to the visible viewport on resize / orientation
   * change so it never becomes unreachable on small screens.
   */
  function ensureFAB() {
    if (document.getElementById('ms2-fab')) return;

    const fab = document.createElement('button');
    fab.id    = 'ms2-fab';
    fab.title = 'Tap: speed-dial  |  Hold: settings';
    fab.innerHTML = `${SVG_SETTINGS}<div id="ms2-fab-ring"></div>`;
    fab.style.right  = CFG.fabRight  + 'px';
    fab.style.bottom = CFG.fabBottom + 'px';
    document.body.appendChild(fab);

    const ring = fab.querySelector('#ms2-fab-ring');

    if (!gget('ms2_v2_hintSeen', false)) {
      gset('ms2_v2_hintSeen', true);
      const hint = document.createElement('div');
      hint.id = 'ms2-fab-hint';
      hint.textContent = 'Tap for menu • Hold for settings';
      document.body.appendChild(hint);
      setTimeout(() => {
        const r = fab.getBoundingClientRect();
        hint.style.right  = (document.documentElement.clientWidth  - r.left + 6) + 'px';
        hint.style.bottom = (document.documentElement.clientHeight - r.top  + 6) + 'px';
      }, 0);
      setTimeout(() => hint.remove(), 3500);
    }

    let startX, startY, startRight, startBottom;
    let wasMoved = false, longFired = false;
    let longTimer = null, ringTimer = null, ringDelayTimer = null;

    const RING_DELAY = 280;

    function startProgress() {
      const t0 = performance.now();
      const remaining = LONG_PRESS_MS - RING_DELAY; 
      const step = now => {
        const pct = Math.min(100, ((now - t0) / remaining) * 100);
        ring.style.background = `conic-gradient(rgba(139,92,246,0.75) ${pct}%, transparent ${pct}%)`;
        if (pct < 100) ringTimer = requestAnimationFrame(step);
      };
      ringTimer = requestAnimationFrame(step);
    }

    function cancelProgress() {
      clearTimeout(ringDelayTimer);
      cancelAnimationFrame(ringTimer);
      ringDelayTimer = null;
      ringTimer = null;
      ring.style.background = 'none';
    }

    function beginDrag(cx, cy) {
      wasMoved  = false;
      longFired = false;
      startX      = cx;
      startY      = cy;
      startRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
      startBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
      fab.classList.add('ms2-pressing');

      ringDelayTimer = setTimeout(() => {
        if (!wasMoved && !longFired) startProgress();
      }, RING_DELAY);

      longTimer = setTimeout(() => {
        if (!wasMoved) {
          longFired = true;
          cancelProgress();
          fab.classList.remove('ms2-pressing');
          closeDial();
          openSettingsModal('general');
        }
      }, LONG_PRESS_MS);
    }

    function moveDrag(cx, cy) {
      const dx = cx - startX, dy = cy - startY;
      if (Math.abs(dx) > 6 || Math.abs(dy) > 6) {
        if (!wasMoved) {
          wasMoved = true;
          clearTimeout(longTimer);
          cancelProgress();
          fab.classList.remove('ms2-pressing');
          fab.classList.add('ms2-dragging');
          closeDial();
        }
      }
      if (!wasMoved) return;
      
      const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, S = 44;
      fab.style.right  = Math.max(8, Math.min(W - S - 8, startRight  - dx)) + 'px';
      fab.style.bottom = Math.max(8, Math.min(H - S - 8, startBottom - dy)) + 'px';
    }

    function endDrag() {
      clearTimeout(longTimer);
      cancelProgress();
      fab.classList.remove('ms2-dragging', 'ms2-pressing');
      if (wasMoved) {
        CFG.fabRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
        CFG.fabBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
      } else if (!longFired) {
        toggleDial();
      }
    }

    fab.addEventListener('mousedown', e => {
      e.preventDefault();
      beginDrag(e.clientX, e.clientY);
      const onMove = ev => moveDrag(ev.clientX, ev.clientY);
      const onUp   = () => {
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup',   onUp);
        endDrag();
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup',   onUp);
    });

    fab.addEventListener('touchstart', e => {
      e.preventDefault();
      beginDrag(e.touches[0].clientX, e.touches[0].clientY);
      const onMove = ev => { ev.preventDefault(); moveDrag(ev.touches[0].clientX, ev.touches[0].clientY); };
      const onEnd  = () => {
        document.removeEventListener('touchmove', onMove);
        document.removeEventListener('touchend',  onEnd);
        document.removeEventListener('touchcancel', onEnd);
        endDrag();
      };
      document.addEventListener('touchmove', onMove, { passive: false });
      document.addEventListener('touchend',  onEnd);
      document.addEventListener('touchcancel', onEnd);
    }, { passive: false });
  }

  // ─── SPA NAVIGATION ────────────────────────────────────────────────────────

  let _lastPathname = '';
  let _lastCharId   = null;   
  /**
   * Called on every SPA route change (history pushState / popState intercept).
   * Re-evaluates whether chat-specific features (observer, auto-summary) should
   * be active for the new URL and starts/stops them accordingly.
   */
  function onRouteChange() {
    if (location.pathname === _lastPathname) return;
    _lastPathname = location.pathname;
    setTimeout(() => {
      ensureFAB();
      
      if (isOnChatPage()) {
        stopObserver();

        stopAutoSumObserver();
        _lastSeenText       = '';
        _cachedLastBotIndex = -1;
        _cachedLastBotText  = '';
        if (CFG.autoNotify) startObserver();
        if (getAutoSumEvery() > 0) startAutoSumObserver();
        
        clearAccumulated();

        const newCharId = getCurrentCharId();
        if (newCharId && newCharId !== _lastCharId) {

          setTimeout(() => {
            const name = extractChatNameFromDOM();
            if (name) saveChatName(name, ctxKey());
          }, 800);

          const pastCount = countSumHistoryForCurrentChat();
          
          const storedName = getChatName(ctxKey())
            || getSumHistory().find(h => h.conv === ctxKey())?.chatName
            || '';
          const charLabel  = storedName ? ` — <strong>${escHtml(storedName)}</strong>` : '';
          
          const lastSnap  = getFabSumLast();
          const snapInfo  = lastSnap
            ? (() => {
                const mins = Math.round((Date.now() - lastSnap.ts) / 60000);
                return mins < 60
                  ? ` · last summary ${mins < 1 ? 'just now' : `${mins}m ago`}`
                  : ` · last summary ${Math.round(mins / 60)}h ago`;
              })()
            : '';
          if (pastCount > 0) {
            toastHTML(
              `${SVG_SUMMARISE} Switched chat${charLabel} — <strong>${pastCount}</strong> past summar${pastCount !== 1 ? 'ies' : 'y'}${snapInfo}`,
              4500
            );
          } else if (_lastCharId !== null) {

            toastHTML(`${SVG_SUMMARISE} Switched chat${charLabel} — no past summaries yet`, 3000);
          }
          
          _sumHistShowAll = false;
        }
        _lastCharId = newCharId;

        if (getAutoLoadGlobal()) {
          setTimeout(() => {
            if (getContext().trim()) return; 
            const charId = getCurrentCharId();
            const mem = (charId && getCharGlobalMemory(charId).trim())
                     || getGlobalMemory().trim();
            if (mem) {
              saveContext(mem);
              toastHTML(`${SVG_FOLDER} Memory auto-loaded for this chat`, 3000);
            }
          }, 700);
        }
      } else {
        stopObserver();
        stopAutoSumObserver();
        _cachedLastBotIndex = -1;
        _cachedLastBotText  = '';
        closeDial();
        clearAccumulated();
        _lastCharId = null;

        // Clear the persisted character ID & name when navigating to a neutral page
        // (home, explore, search, etc.) — NOT on character card pages, since those
        // will immediately overwrite the stored ID anyway when _p2pGetCharId() runs.
        const _path = location.pathname;
        const _isNeutral = !_path.startsWith('/characters/') && !_path.startsWith('/chats/');
        if (_isNeutral) {
          try { GM_setValue(P2P_GM_LAST_CHAR, ''); } catch {}
          try { GM_setValue(P2P_GM_LAST_CHAR_NAME, ''); } catch {}
        }
      }
    }, 400);
  }

  if (!history.__ms2_patched) {
    const _histPush    = history.pushState.bind(history);
    const _histReplace = history.replaceState.bind(history);
    history.pushState    = (...a) => { _histPush(...a);    onRouteChange(); };
    history.replaceState = (...a) => { _histReplace(...a); onRouteChange(); };
    history.__ms2_patched = true;
  }
  window.addEventListener('popstate', onRouteChange);

  window.addEventListener('resize', () => {
    const fab = document.getElementById('ms2-fab');
    if (!fab) return;
    const W = document.documentElement.clientWidth;
    const H = document.documentElement.clientHeight;
    const S = 44;
    const curRight  = parseInt(fab.style.right,  10) || CFG.fabRight;
    const curBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
    fab.style.right  = Math.max(8, Math.min(W - S - 8, curRight))  + 'px';
    fab.style.bottom = Math.max(8, Math.min(H - S - 8, curBottom)) + 'px';
  });

  // ─── INIT ──────────────────────────────────────────────────────────────────

  /**
   * Script bootstrap — called once after `document-idle` + 500 ms delay.
   *
   * Initialisation order:
   *  1. `SelectorEngine.startSelfHealing` + `loadRemote`
   *  2. `NetworkInterceptor.init` (patches page fetch + XHR)
   *  3. `migrateStorage` (one-time GM storage schema upgrades)
   *  4. `initAPInterceptor` (advanced prompt injection)
   *  5. `apWatchDeletions` (deleted-message fingerprinting)
   *  6. `ensureFAB` (floating action button)
   *  7. Conditional observers (auto-notify, auto-summary) if on a chat page
   *  8. `jv5Bus` `scriptInitialized` event for external tooling
   *  9. `unsafeWindow.__jv5` diagnostic interface
   */
  // ─── JV5 SELF-TEST SUITE ─────────────────────────────────────────────────
  // Silent background tests. Run once ~4 s after load so they never block init.
  // All results go to console.group('[JV5 SelfTest]') — open DevTools to read.
  // A global window.__jv5Tests is also set so the Diagnostics script can poll it.
  //
  // Tests covered:
  //   1. AES-GCM-256 encrypt → decrypt round-trip (known-plaintext)
  //   2. Wrong-password rejection (must return null, not throw)
  //   3. PBKDF2 key-cache hit (second call must be <5 ms)
  //   4. Anti-replay: message timestamped 7 h ago must be dropped
  //   5. Anti-replay: future message (clock skew >6 h) must be dropped
  //   6. IV uniqueness: 20 encryptions must produce 20 distinct IVs
  //   7. PSK convenience wrappers (pskEncrypt / pskDecrypt round-trip)
  //   8. Corrupted ciphertext (flip one byte) must fail gracefully
  //   9. Empty-string body edge case must not throw
  //  10. GM storage write → read round-trip
  //
  // Why silent? Users never see a test UI. If something breaks, the developer
  // opens DevTools and sees exactly which assertion failed and why.

  const _selfTestResults = [];

  function _stAssert(name, pass, detail) {
    const status = pass ? 'PASS' : 'FAIL';
    _selfTestResults.push({ name, status, detail: detail || '' });
    if (!pass) {
      console.error(`[JV5 SelfTest] ❌ FAIL — ${name}${detail ? ': ' + detail : ''}`);
    }
    return pass;
  }

  async function _runSelfTests() {
    const T0 = performance.now();
    console.group('[JV5 SelfTest] Running silent background tests…');

    const PW   = 'test-password-abc';
    const ROOM = 'test-room-001';

    // ── 1. Round-trip ─────────────────────────────────────────────────────
    try {
      const plain = { text: 'hello world', user: 'tester', ts: Date.now() };
      const enc   = await P2PCrypto.encrypt(plain, PW, ROOM);
      const dec   = await P2PCrypto.decrypt(enc, PW, ROOM);
      const ok    = dec && dec.text === plain.text && dec.user === plain.user;
      _stAssert('encrypt→decrypt round-trip', ok,
        ok ? '' : `got: ${JSON.stringify(dec)}`);
    } catch (e) {
      _stAssert('encrypt→decrypt round-trip', false, e.message);
    }

    // ── 1b. Padding obscures message length ───────────────────────────────
    try {
      const short = { text: 'hi' };
      const long  = { text: 'x'.repeat(300) };
      const encS  = await P2PCrypto.encrypt(short, PW, ROOM);
      const encL  = await P2PCrypto.encrypt(long,  PW, ROOM);
      // Both should be multiples of 256 bytes (plus GCM tag), so short != long
      // but short should be padded to the same first block as medium messages
      const sLen  = encS.ciphertext.length;
      const lLen  = encL.ciphertext.length;
      _stAssert('padding: short message padded to ≥256 bytes', sLen >= 256,
        `ciphertext length was ${sLen}`);
      _stAssert('padding: long message padded to multiple of 256', lLen % 256 === 16 /* GCM auth tag only */,
        `ciphertext length was ${lLen}`);
    } catch (e) {
      _stAssert('padding round-trip', false, e.message);
    }

    // ── 2. Wrong-password rejection ────────────────────────────────────────
    try {
      const enc = await P2PCrypto.encrypt({ text: 'secret' }, PW, ROOM);
      const dec = await P2PCrypto.decrypt(enc, 'wrong-password', ROOM);
      _stAssert('wrong-password returns null', dec === null,
        dec !== null ? `expected null, got: ${JSON.stringify(dec)}` : '');
    } catch (e) {
      _stAssert('wrong-password returns null', false, `threw instead of returning null: ${e.message}`);
    }

    // ── 3. Key-cache performance ───────────────────────────────────────────
    try {
      // First call — cache miss (already warmed in test 1, but use a new combo)
      const PW2 = 'cache-test-pw'; const R2 = 'cache-test-room';
      const t1 = performance.now();
      await P2PCrypto._deriveKey(PW2, R2);
      const missMs = performance.now() - t1;

      const t2 = performance.now();
      await P2PCrypto._deriveKey(PW2, R2);
      const hitMs = performance.now() - t2;

      _stAssert('key-cache hit is fast (<5 ms)', hitMs < 5,
        `cache-miss=${missMs.toFixed(1)}ms cache-hit=${hitMs.toFixed(1)}ms`);
    } catch (e) {
      _stAssert('key-cache hit is fast (<5 ms)', false, e.message);
    }

    // ── 4. Anti-replay: stale message (7 h old) ───────────────────────────
    try {
      const stale = { text: 'old message', _ts: Date.now() - 1000 * 60 * 60 * 7, _seq: 1 };
      const enc   = await P2PCrypto.encrypt(stale, PW, ROOM);
      // Manually patch _ts inside the ciphertext by re-encrypting with the stale timestamp
      // The encrypt() always writes a fresh _ts, so we must build the envelope manually.
      const key  = await P2PCrypto._deriveKey(PW, ROOM);
      const iv   = crypto.getRandomValues(new Uint8Array(12));
      const raw  = new TextEncoder().encode(JSON.stringify(stale));
      const ct   = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, raw);
      const staleEnc = { encrypted: true, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ct)), v: 1 };
      const dec  = await P2PCrypto.decrypt(staleEnc, PW, ROOM);
      _stAssert('stale message dropped (>6 h old)', dec === null,
        dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
    } catch (e) {
      _stAssert('stale message dropped (>6 h old)', false, e.message);
    }

    // ── 5. Anti-replay: future message (>6 h clock skew) ─────────────────
    try {
      const future = { text: 'future msg', _ts: Date.now() + 1000 * 60 * 60 * 7, _seq: 2 };
      const key  = await P2PCrypto._deriveKey(PW, ROOM);
      const iv   = crypto.getRandomValues(new Uint8Array(12));
      const raw  = new TextEncoder().encode(JSON.stringify(future));
      const ct   = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, raw);
      const futureEnc = { encrypted: true, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ct)), v: 1 };
      const dec  = await P2PCrypto.decrypt(futureEnc, PW, ROOM);
      _stAssert('future message dropped (>6 h skew)', dec === null,
        dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
    } catch (e) {
      _stAssert('future message dropped (>6 h skew)', false, e.message);
    }

    // ── 6. IV uniqueness (20 encryptions) ─────────────────────────────────
    try {
      const ivSet = new Set();
      for (let i = 0; i < 20; i++) {
        const enc = await P2PCrypto.encrypt({ text: `msg-${i}` }, PW, ROOM);
        ivSet.add(JSON.stringify(enc.iv));
      }
      _stAssert('IV uniqueness (20 encryptions → 20 distinct IVs)', ivSet.size === 20,
        `got ${ivSet.size} unique IVs`);
    } catch (e) {
      _stAssert('IV uniqueness', false, e.message);
    }

    // ── 7. PSK convenience wrappers ────────────────────────────────────────
    try {
      const obj  = { text: 'psk test', user: 'u1', ts: Date.now() };
      const enc  = await pskEncrypt(obj);
      const dec  = await pskDecrypt(enc);
      const ok   = dec && dec.text === obj.text && dec.user === obj.user;
      _stAssert('pskEncrypt→pskDecrypt round-trip', ok,
        ok ? '' : `got: ${JSON.stringify(dec)}`);
    } catch (e) {
      _stAssert('pskEncrypt→pskDecrypt round-trip', false, e.message);
    }

    // ── 8. Corrupted ciphertext (must fail gracefully, not throw) ──────────
    try {
      const enc = await P2PCrypto.encrypt({ text: 'real' }, PW, ROOM);
      const bad = { ...enc, ciphertext: enc.ciphertext.map((b, i) => i === 5 ? b ^ 0xff : b) };
      const dec = await P2PCrypto.decrypt(bad, PW, ROOM);
      _stAssert('corrupted ciphertext returns null (no throw)', dec === null,
        dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
    } catch (e) {
      _stAssert('corrupted ciphertext returns null (no throw)', false, `threw: ${e.message}`);
    }

    // ── 9. Empty-string body edge case ─────────────────────────────────────
    try {
      const enc = await P2PCrypto.encrypt({ text: '' }, PW, ROOM);
      const dec = await P2PCrypto.decrypt(enc, PW, ROOM);
      _stAssert('empty-string body round-trip', dec !== null && dec.text === '',
        dec ? '' : 'returned null for empty-string body');
    } catch (e) {
      _stAssert('empty-string body round-trip', false, e.message);
    }

    // ── 10. GM storage write → read round-trip ─────────────────────────────
    try {
      const KEY = '__jv5_selftest_probe__';
      const VAL = `probe-${Date.now()}`;
      gset(KEY, VAL);
      const readBack = gget(KEY, null);
      const ok = readBack === VAL;
      // Clean up
      try { GM_deleteValue(KEY); } catch {}
      delete _cfgCache[KEY];
      _stAssert('GM storage write→read round-trip', ok,
        ok ? '' : `wrote "${VAL}", read back "${readBack}"`);
    } catch (e) {
      _stAssert('GM storage write→read round-trip', false, e.message);
    }

    // ── Summary ────────────────────────────────────────────────────────────
    const elapsed  = (performance.now() - T0).toFixed(0);
    const passed   = _selfTestResults.filter(r => r.status === 'PASS').length;
    const failed   = _selfTestResults.filter(r => r.status === 'FAIL').length;
    const total    = _selfTestResults.length;

    if (failed === 0) {
      console.log(`[JV5 SelfTest] ✅ All ${total} tests passed in ${elapsed} ms`);
    } else {
      console.warn(`[JV5 SelfTest] ⚠️ ${failed}/${total} tests FAILED in ${elapsed} ms — see errors above`);
    }

    console.table(_selfTestResults.map(r => ({ Test: r.name, Status: r.status, Detail: r.detail })));
    console.groupEnd();

    // Expose results on the __jv5 bridge so Diagnostics script can pick them up
    try {
      if (unsafeWindow.__jv5) {
        unsafeWindow.__jv5.selfTests = {
          ran: true,
          passed,
          failed,
          total,
          elapsedMs: parseInt(elapsed),
          results: _selfTestResults,
          ranAt: new Date().toISOString(),
        };
      }
    } catch {}
  }

  // Run 6 s after load — gives init() (500ms delay) plenty of time to set __jv5 bridge.
  // Call window.__jv5.runSelfTests() anytime to re-run manually from the console.
  setTimeout(() => { _runSelfTests().catch(e => console.error('[JV5 SelfTest] Runner crashed:', e)); }, 6000);

  function init() {
    // Initialize advanced engineered systems in order
    try { selectorEngine.startSelfHealing(); } catch {}
    try { selectorEngine.loadRemote(); } catch {}
    try { netInterceptor.init(); } catch {}
    try { migrateStorage(); } catch {}

    _initRemoteConfig();
    initAPInterceptor();
    apWatchDeletions();

    // ── First-run security warning ─────────────────────────────────────────────
    // Shown once on fresh install. Stored in GM so it never repeats.
    // Purpose: warn users about forked/modified copies before they use the script.
    const _WARN_KEY = 'jv5_install_warn_v1';
    if (!GM_getValue(_WARN_KEY, false)) {
      GM_setValue(_WARN_KEY, true);
      // Delay slightly so the page is ready
      setTimeout(() => _showInstallWarning(), 800);
    }

    ensureFAB();
    if (isOnChatPage() && CFG.autoNotify) startObserver();
    if (isOnChatPage() && getAutoSumEvery() > 0) startAutoSumObserver();

    bus.emit('scriptInitialized', { version: '5.7.4' });

    // ── Diagnostic interface (read by JV5 Diagnostics userscript) ──────────
    // SECURITY: Only exposed when the user has explicitly enabled developer mode
    // in GM storage (jv5_dev_mode = true). This prevents page-context scripts on
    // a compromised janitorai.com from calling getLatestAIText() or runSelfTests().
    const _devModeEnabled = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
    try {
      unsafeWindow.__jv5 = {
        version:     '5.7.8',
        // Safe read-only selectors — no user data exposed
        virtuosoWorking: () => document.querySelectorAll(VIRTUOSO_SEL).length > 0,
        liveVirtuosoSel: () => VIRTUOSO_SEL,
        liveMsgBodySel:  () => MSG_BODY_SEL,
        liveScrollerSel: () => SELECTOR_CONFIG.messagesMain,
        selectorHits:    () => Object.fromEntries(selectorEngine.hits || new Map()),
        // Diagnostic-safe: boolean/string metadata, no credentials exposed
        hasApiKey:     () => !!CFG.apiKey,
        model:         () => CFG.model,
        endpoint:      () => CFG.endpoint,
        authMode:      () => CFG.authMode,
        apEnabled:     () => AP.enabled,
        // Dev-mode-only: functions that could expose AI response content
        getLatestText: _devModeEnabled ? getLatestAIText : undefined,
        realScrollerInfo: _devModeEnabled ? (() => {
          const s = _findRealScroller();
          if (!s) return null;
          return {
            sel: _buildScrollerSel(s),
            scrollHeight: s.scrollHeight,
            clientHeight: s.clientHeight,
            ratio: (s.scrollHeight / Math.max(s.clientHeight, 1)).toFixed(2),
          };
        }) : undefined,
        selfTests:     null,
        runSelfTests:  _devModeEnabled ? (() => _runSelfTests()) : undefined,
        // ── Real crypto/defense hooks (v6.4+ diagnostics) ──────────────────
        // These call JV5's ACTUAL encryption/validation code paths so the
        // diagnostic can never give a false "healthy" result while testing
        // a divergent standalone reimplementation.
        cryptoFlags: () => ({
          argon2Emulation: gget('jv5_use_argon2', true),
          ed25519Admin:    gget('jv5_use_ed25519_admin', true),
          ratchetGlobal:   gget('jv5_ratchet_global', true),
        }),
        // Round-trips a throwaway payload through the REAL PSK pipeline
        // (P2PCrypto._deriveKey incl. argon2-emulation/HKDF + padding +
        // anti-replay timestamp), using this user's actual derived PSK.
        pskRoundTrip: async () => {
          try {
            const probe = { __jvdiag: true, n: Math.random() };
            const enc = await pskEncrypt(probe);
            if (!enc || !enc.encrypted) return { ok: false, stage: 'encrypt' };
            const dec = await pskDecrypt(enc);
            return { ok: !!dec && dec.n === probe.n, stage: dec ? 'ok' : 'decrypt' };
          } catch (e) { return { ok: false, stage: 'error', err: e.message }; }
        },
        // Tests the real SSRF block-list regex against representative inputs
        ssrfBlocked: (url) => _SSRF_BLOCK_RE.test(String(url || '')),
        // Exercises the prototype-pollution-safe allowlist merge with a
        // crafted __proto__ payload — returns true only if the merge is safe.
        safeMergeOk: () => {
          try {
            const target = {};
            _safeMergeMsg(target, JSON.parse('{"__proto__":{"jvdiagPwned":true},"text":"x"}'));
            return target.jvdiagPwned !== true && ({}).jvdiagPwned !== true && target.text === 'x';
          } catch { return false; }
        },
        // Verifies an HMAC admin signature using the real (possibly SHA-512+HKDF)
        // verification path. Returns false on any bad/missing sig — safe to expose.
        verifyAdminSig: _devModeEnabled ? ((msgId, ts, sig) => _verifyAdminSig(msgId, ts, sig)) : undefined,
        // Last few injectAndSend() outcomes — see _logSendAttempt above.
        sendLog: () => _sendAttemptLog.slice(),
        // Enable dev mode via browser console: GM_setValue('jv5_dev_mode', true) then reload
      };
    } catch {}
  }

  // ─── INSTALL SECURITY WARNING (shown once on first install) ─────────────────
  function _showInstallWarning() {
    if (!document.body) return;

    const backdrop = document.createElement('div');
    backdrop.style.cssText = `
      position:fixed;inset:0;z-index:2147483647;
      background:rgba(0,0,0,0.85);
      display:flex;align-items:flex-start;justify-content:center;
      font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
      backdrop-filter:blur(4px);
      padding:env(safe-area-inset-top,12px) 12px 12px;
      overflow-y:auto;
      -webkit-overflow-scrolling:touch;
    `;

    const modal = document.createElement('div');
    modal.style.cssText = `
      background:#111827;border:1px solid rgba(239,68,68,0.5);
      border-radius:14px;padding:0;width:min(440px,100%);
      box-shadow:0 20px 50px rgba(0,0,0,0.7),0 0 0 1px rgba(239,68,68,0.15);
      overflow:hidden;margin:auto;flex-shrink:0;
    `;

    modal.innerHTML = `
      <div style="background:linear-gradient(135deg,rgba(239,68,68,0.18),rgba(127,29,29,0.25));padding:14px 16px 12px;border-bottom:1px solid rgba(239,68,68,0.2);">
        <div style="display:flex;align-items:center;gap:9px;">
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
          <div>
            <div style="font-size:13.5px;font-weight:700;color:#f87171;letter-spacing:.2px;">Security Notice — Read Before Using</div>
            <div style="font-size:10.5px;color:#9ca3af;margin-top:1px;">JanitorV5 by eivls · First Install</div>
          </div>
        </div>
      </div>

      <div style="padding:12px 14px 10px;display:flex;flex-direction:column;gap:9px;">

        <div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.25);border-radius:9px;padding:10px 12px;">
          <div style="font-size:10.5px;font-weight:700;color:#f87171;letter-spacing:.4px;margin-bottom:5px;">⚠️ FORKED OR MODIFIED COPIES ARE DANGEROUS</div>
          <div style="font-size:12px;color:#d1d5db;line-height:1.6;">
            Anyone can copy and redistribute this script with <strong style="color:#f87171;">malicious changes</strong> — keyloggers, API key theft, message interception — while keeping it looking identical to the original.<br><br>
            <strong style="color:#fca5a5;">Only install JanitorV5 from the official GreasyFork page.</strong>
            Check that the author is <strong style="color:#e5e7eb;">eivls</strong> and the URL starts with
            <code style="background:rgba(255,255,255,0.07);padding:1px 5px;border-radius:4px;font-size:10.5px;color:#a78bfa;">greasyfork.org/scripts/</code>
          </div>
        </div>

        <div style="background:rgba(251,191,36,0.07);border:1px solid rgba(251,191,36,0.2);border-radius:9px;padding:10px 12px;">
          <div style="font-size:10.5px;font-weight:700;color:#fcd34d;letter-spacing:.4px;margin-bottom:5px;">🔑 YOUR API KEY</div>
          <div style="font-size:12px;color:#d1d5db;line-height:1.6;">
            Your API key is stored locally in Tampermonkey's GM storage — <strong>never transmitted anywhere</strong> by this script. A malicious fork could silently exfiltrate it. If you ever pasted your key into a script you didn't verify, <strong style="color:#fcd34d;">regenerate it now</strong> from your provider's dashboard.
          </div>
        </div>

        <div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.2);border-radius:9px;padding:10px 12px;">
          <div style="font-size:10.5px;font-weight:700;color:#6ee7b7;letter-spacing:.4px;margin-bottom:5px;">✅ HOW TO VERIFY YOU HAVE THE REAL SCRIPT</div>
          <div style="font-size:12px;color:#d1d5db;line-height:1.65;">
            <strong>1.</strong> Open Tampermonkey → this script → check the <em>@downloadURL</em> at the top.<br>
            <strong>2.</strong> It must point to <code style="background:rgba(255,255,255,0.07);padding:1px 4px;border-radius:3px;font-size:10.5px;color:#a78bfa;">greasyfork.org</code> — not a personal GitHub, Pastebin, or unknown domain.<br>
            <strong>3.</strong> The author listed on GreasyFork must be <strong style="color:#e5e7eb;">eivls</strong> (<a href="https://tiktok.com/@eivls" target="_blank" style="color:#a78bfa;text-decoration:none;">@eivls on TikTok</a>).<br>
            <strong>4.</strong> Never install a "better" or "updated" version someone shares in DMs or Discord without verifying the source yourself.
          </div>
        </div>

        <div style="font-size:10.5px;color:#6b7280;line-height:1.5;text-align:center;padding:2px 0;">
          This notice appears once and will not show again.<br>Community Chat uses end-to-end encryption — your messages are private from the relay server.
        </div>

      </div>

      <div style="padding:0 14px 14px;display:flex;gap:8px;justify-content:center;">
        <button id="jv5-warn-close" style="
          background:linear-gradient(135deg,#6d28d9,#4f46e5);
          border:none;border-radius:8px;padding:10px 28px;
          font-size:13px;font-weight:600;color:#fff;cursor:pointer;
          box-shadow:0 4px 14px rgba(109,40,217,0.4);
          width:100%;max-width:260px;
          -webkit-tap-highlight-color:transparent;
        ">I understand — continue</button>
      </div>
    `;


    backdrop.appendChild(modal);
    document.body.appendChild(backdrop);

    const close = () => backdrop.remove();
    modal.querySelector('#jv5-warn-close').addEventListener('click', close);
    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
    document.addEventListener('keydown', function esc(e) {
      if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
  } else {
    setTimeout(init, 500);
  }


})();