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.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Advertisement:

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

Advertisement:

// ==UserScript==
// @name         JanitorV5
// @namespace    https://janitorai.com/
// @version      5.11.1
// @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.11.1 — Self-healing, multi-fallback, deep React source connected, production-grade structure

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

  const _cfgCache = {};

  
  const gget = (k, d) => {
    if (k in _cfgCache) return _cfgCache[k];
    try { _cfgCache[k] = GM_getValue(k, d); return _cfgCache[k]; } catch { return d; }
  };

  
  const gset = (k, v) => {
    _cfgCache[k] = v;
    try {
      GM_setValue(k, v);
    } catch (err) {
      // Storage failure: in-memory cache now holds a value that was NOT persisted.
      // This can cause confusing state loss (e.g. settings that appear saved but
      // reset on next page load). Log it so the issue is visible in the console.
      console.error(`[JanitorV5] GM storage write failed for key "${k}":`, err);
    }
  };

  // ─── 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-512 (600,000 iterations).
  // 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';
  // Recovery code — allows PIN reset without knowing the current PIN.
  // The raw code is shown once and never stored; only a SHA-256 hash is kept.
  // A separate AES-GCM blob lets the key be recovered even if the PIN is forgotten.
  const _KEY_RECOVERY_HASH  = 'jv5_pin_recovery_hash'; // hex SHA-256 of the code
  const _KEY_RECOVERY_ENC   = 'jv5_pin_recovery_enc';  // key encrypted under PBKDF2(code)
  const _KEY_RECOVERY_SALT  = 'jv5_pin_recovery_salt'; // PBKDF2 salt for recovery blob
  // Upgraded from SHA-256/310k → SHA-512/350k → SHA-512/600k.
  // OWASP minimum for PBKDF2-SHA-512 is 210,000; 600,000 gives a 2.8× safety
  // margin over that floor and makes offline brute-force of captured ciphertext
  // significantly more expensive on both CPU and GPU attackers.
  const _PBKDF2_ITERS = 600000;
  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 };

  // ─── PIN BRUTE-FORCE LOCKOUT ────────────────────────────────────────────────
  // Tracks consecutive failed PIN unlock attempts. After 5 failures the unlock
  // is frozen for an exponentially-increasing delay (max 15 min).
  // The counter lives only in memory — a page reload resets it intentionally
  // (the ciphertext itself is the durable protection).
  let _pinFailCount  = 0;
  let _pinLockUntil  = 0;  // epoch ms — lockout expires at this time

  // Sentinel values returned by _pinCheckLockout() so callers can distinguish
  // between "never failed" (no lockout ever triggered) and "lockout expired"
  // (was locked out but the window has passed). Both allow an attempt, but
  // future maintainers may want to treat them differently (e.g. to show a
  // "lockout lifted" notice only in the expired case).
  const PIN_LOCKOUT_NEVER_FAILED = /** @type {const} */ ('NEVER_FAILED');
  const PIN_LOCKOUT_EXPIRED      = /** @type {const} */ ('EXPIRED');

  function _pinRecordFailure() {
    _pinFailCount++;
    const delaySec = Math.min(30 * Math.pow(2, _pinFailCount - 1), 900); // cap at 15 min
    _pinLockUntil = Date.now() + delaySec * 1000;
  }
  
  function _pinCheckLockout() {
    if (_pinFailCount === 0) return PIN_LOCKOUT_NEVER_FAILED;
    const remaining = Math.ceil((_pinLockUntil - Date.now()) / 1000);
    if (remaining > 0) return remaining;
    return PIN_LOCKOUT_EXPIRED; // lockout window passed — allow attempt
  }

  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 {
      // SECURITY: Always generate a fresh salt — even when re-encrypting
      // (e.g. on PIN change). Reusing the existing salt would mean an
      // attacker who captured an earlier ciphertext+salt pair could
      // pre-compute the PBKDF2 derivation for candidate PINs once and reuse
      // that work against the new ciphertext too. A fresh salt per
      // encryption invalidates any such pre-computation.
      const saltArr = crypto.getRandomValues(new Uint8Array(32));
      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
      setTimeout(_syncFabSecBadge, 0); // remove warning badge now that PIN is active

      // ── PSK half B encryption (v5.10.0 hardening) ───────────────────────────
      // Encrypt jv5_psk_b under the same PIN-derived CryptoKey so a rogue script
      // cannot read the plaintext PSK half and reconstruct the Community Chat key.
      // Non-fatal: failure here does NOT block API key encryption.
      if (_storedHalfB) {
        try {
          const pskIv  = crypto.getRandomValues(new Uint8Array(12));
          const pskCt  = await crypto.subtle.encrypt(
            { name: 'AES-GCM', iv: pskIv }, ck, new TextEncoder().encode(_storedHalfB));
          const pskBlob = new Uint8Array(12 + pskCt.byteLength);
          pskBlob.set(pskIv, 0); pskBlob.set(new Uint8Array(pskCt), 12);
          try { GM_setValue(_pskGmKeyEnc, _arrToB64(pskBlob)); } catch {}
          try { GM_deleteValue(_pskGmKey); } catch {} // remove plaintext PSK half
        } catch { /* non-fatal — PSK still protected by overall encryption */ }
      }

      return true;
    } catch { return false; } // error detail intentionally withheld (avoids info leak)
  }

  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

      // ── PSK half B unlock (v5.10.0 hardening) ────────────────────────────────
      // Two paths:
      //   A. Encrypted copy (jv5_psk_b_enc) exists → decrypt and set _storedHalfB.
      //   B. Plaintext copy (jv5_psk_b) still exists (existing user, PIN just set) →
      //      encrypt it now and delete the plaintext (migration path).
      try {
        const _pskEncBlob64 = (() => { try { return GM_getValue(_pskGmKeyEnc, ''); } catch { return ''; } })();
        if (_pskEncBlob64) {
          // Path A: decrypt the protected PSK half
          const pskBlob = _b64ToArr(_pskEncBlob64);
          const pskIv   = pskBlob.slice(0, 12);
          const pskCt   = pskBlob.slice(12);
          const pskPln  = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: pskIv }, ck, pskCt);
          _storedHalfB  = new TextDecoder().decode(pskPln);
          await _rederivePsk(); // P2P_PSK was null — now re-derive it
        } else if (_storedHalfB) {
          // Path B: plaintext PSK half still in GM — encrypt it under PIN key (migrate)
          try {
            const pskIv  = crypto.getRandomValues(new Uint8Array(12));
            const pskCt  = await crypto.subtle.encrypt(
              { name: 'AES-GCM', iv: pskIv }, ck, new TextEncoder().encode(_storedHalfB));
            const pskBlob = new Uint8Array(12 + pskCt.byteLength);
            pskBlob.set(pskIv, 0); pskBlob.set(new Uint8Array(pskCt), 12);
            try { GM_setValue(_pskGmKeyEnc, _arrToB64(pskBlob)); } catch {}
            try { GM_deleteValue(_pskGmKey); } catch {} // remove plaintext
          } catch { /* migration failure non-fatal */ }
        }
      } catch { /* PSK unlock failure non-fatal — API key still returns normally */ }

      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; }
  }

  // Syncs the security badge on the FAB after PIN state changes.
  // Called by _pinEncryptKey and _pinRemove to keep the badge current.
  function _syncFabSecBadge() {
    const fab = document.getElementById('ms2-fab');
    if (!fab) return;
    const existing = document.getElementById('ms2-fab-sec-badge');
    if (_pinIsActive()) {
      existing?.remove(); // PIN set — badge no longer needed
    } else if (!existing) {
      const badge = document.createElement('span');
      badge.id    = 'ms2-fab-sec-badge';
      badge.title = 'No PIN set — API key stored unencrypted. Open Settings → API Key → Set PIN.';
      fab.appendChild(badge);
    }
  }

  // ─── PIN GENERATOR ────────────────────────────────────────────────────────
  
  function _generateStrongPin(mode = 'chars') {
    if (mode === 'passphrase') {
      const words = [
        'amber','arctic','azure','bacon','badge','birch','blade','blaze','bloom',
        'brave','brook','cedar','chalk','chaos','chest','chief','chill','chrome',
        'cloak','cloud','coast','cobra','coral','crane','creek','crest','crisp',
        'crown','crush','cycle','dance','delta','depot','diver','draft','drake',
        'drift','drone','dusk','eagle','ember','envoy','epoch','evade','exile',
        'fable','feral','ferry','fetch','field','finch','fjord','flare','flask',
        'fleet','flint','float','flood','flora','floss','flute','focal','forge',
        'foxen','frail','frame','fresh','frost','ghost','glade','glare','glyph',
        'grace','grain','grant','grave','graze','grove','gruff','guise','gust',
        'haven','hawk','heist','helix','hippo','honey','honor','horse','hound',
        'human','humid','hurtle','hyena','index','indie','ivory','joker','karma',
        'kayak','knave','lance','latch','lemon','lever','light','lilac','lunar',
        'lustre','maple','march','marsh','melon','merit','metal','moose','morse',
        'mossy','mount','mouse','naval','nerve','nexus','ninja','noble','nomad',
        'north','notch','novel','nymph','ocean','onyx','opera','orbit','otter',
        'oxide','ozone','panda','panel','paper','patch','peach','pearl','pedal',
        'petal','phase','piano','pilot','pixel','plain','plank','plant','pluck',
        'polar','poppy','prism','probe','prose','proxy','pulse','pygmy','quake',
        'quell','query','quest','quiet','quota','raven','reach','realm','relic',
        'renew','resin','rider','rivet','river','robin','rocky','rouge','rover',
        'royal','rugby','runic','rustic','sabre','scout','sepia','serif','shade',
        'shaft','shark','shell','shift','shiny','shore','shrug','sigma','silky',
        'skate','skill','slate','sleek','slick','slope','sloth','smoke','snare',
        'solar','solid','solve','sonic','south','spark','spawn','spear','speck',
        'spell','spice','spike','spine','splay','spore','spoke','spook','spool',
        'sport','spray','sprig','spunk','squid','stain','stale','stalk',
        'stamp','stark','start','steam','steel','steep','steer','stern','stick',
        'sting','stock','stoic','stone','storm','stout','stove','strap','straw',
        'sweep','swift','swipe','swirl','sword','table','talon','taupe','thorn',
        'those','thumb','tiger','tinge','title','toast','token','topaz','torch',
        'track','trail','trait','trawl','trend','trident','trill','trout','trust',
        'tulip','tundra','tuner','twill','twist','ultra','umbra','unify','valor',
        'valve','vapor','vault','venom','verse','vicar','vigor','viola','viper',
        'vista','vivid','vocal','vodka','volar','voter','wader','watch',
        'water','weave','wedge','wheat','wheel','whiff','whirl','whisp','white',
        'whole','whose','wield','winch','witch','wooly','world','wraith','wrath',
        'yacht','yeoman','zebra','zenith','zesty','zonal'
      ];
      // Rejection sampling eliminates modulo bias.
      // With 328 words: 2^32 % 328 = 136, so without rejection the first 136
      // words would be fractionally more likely. Threshold = floor(2^32/328)*328.
      const WORD_COUNT = words.length; // 328
      const THRESHOLD  = Math.floor(4294967296 / WORD_COUNT) * WORD_COUNT; // 4294967168
      const picked = [];
      while (picked.length < 4) {
        const tmp = new Uint32Array(8);
        crypto.getRandomValues(tmp);
        for (const n of tmp) {
          if (picked.length >= 4) break;
          if (n < THRESHOLD) picked.push(words[n % WORD_COUNT]);
        }
      }
      return picked.join('-');
    }
    // 'chars' mode — 14 cryptographically random characters.
    // Charset is exactly 64 characters (power of 2) so b % 64 has zero modulo
    // bias against uniform bytes. Excludes visually ambiguous chars (I, O, i, l, o).
    // While loop guarantees exactly 14 chars regardless of rejection rate —
    // fixed-buffer approaches have a non-trivial chance of returning a short PIN.
    // 64 chars exactly: 24 upper (A-Z no I/O) + 24 lower (a-z no i/o; l kept since
    // digits start at 2 so l vs 1 confusion is not possible) + 8 digits (2-9) + 8 symbols.
    // 256 % 64 === 0 → zero modulo bias. Verified in _runSelfTests.
    const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz23456789!@#$%^&*';
    let pin = '';
    while (pin.length < 14) {
      const bytes = new Uint8Array(20);
      crypto.getRandomValues(bytes);
      for (const b of bytes) {
        if (pin.length >= 14) break;
        pin += charset[b % 64]; // b % 64: no bias since 256 % 64 === 0
      }
    }
    // Guarantee at least one letter so PIN passes the min-8+letter validation
    if (!/[a-zA-Z]/.test(pin)) {
      // Replace last char with a letter — the char at position 0 of charset is 'A'
      pin = pin.slice(0, 13) + 'A';
    }
    return pin;
  }

  // ─── RECOVERY CODE HELPERS ─────────────────────────────────────────────────
  
  function _generateRecoveryCode() {
    // 32 chars — power of 2, so b % 32 has zero modulo bias against uniform bytes.
    // No I, O, 0, 1 to avoid confusion when reading/typing.
    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    let raw = '';
    // Loop until we have 24 chars. Each batch of 16 bytes yields ~14 chars on
    // average (b < 224 check is vestigial here since 256 % 32 === 0 — kept for
    // clarity, removed below in favour of accepting all bytes).
    while (raw.length < 24) {
      const bytes = new Uint8Array(24 - raw.length + 4); // small overshoot
      crypto.getRandomValues(bytes);
      for (const b of bytes) {
        if (raw.length >= 24) break;
        raw += chars[b % 32]; // no bias: 256 / 32 = 8 exactly
      }
    }
    // Group into 6 × 4
    return raw.match(/.{4}/g).join('-');
  }

  // Returns hex SHA-256 of a recovery code (spaces/dashes stripped, uppercased).
  async function _hashRecoveryCode(code) {
    const normalised = code.replace(/[-\s]/g, '').toUpperCase();
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalised));
    return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
  }

  
  async function _storeRecoveryBackup(apiKey, recoveryCode) {
    // Throws on failure — callers must catch and inform the user that
    // the recovery code shown is not backed by stored data.
    const normalised = recoveryCode.replace(/[-\s]/g, '').toUpperCase();
    const saltArr = new Uint8Array(16);
    crypto.getRandomValues(saltArr);
    const saltB64 = btoa(String.fromCharCode(...saltArr));
    const ck = await _pinDeriveKey(normalised, saltArr);
    const iv = new Uint8Array(12);
    crypto.getRandomValues(iv);
    const ct = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      ck,
      new TextEncoder().encode(apiKey)
    );
    const blob = btoa(String.fromCharCode(...iv, ...new Uint8Array(ct)));
    const hash = await _hashRecoveryCode(normalised);
    gset(_KEY_RECOVERY_HASH, hash);
    gset(_KEY_RECOVERY_ENC,  blob);
    gset(_KEY_RECOVERY_SALT, saltB64);
    // Verify the write actually persisted (catches GM quota/permission failures)
    const verify = gget(_KEY_RECOVERY_HASH, '');
    if (verify !== hash) throw new Error('Recovery backup write did not persist — GM storage may be full or restricted.');
  }

  
  async function _recoverWithCode(code) {
    try {
      if (!code || typeof code !== 'string') return null;
      const normalised = code.replace(/[-\s]/g, '').toUpperCase();
      if (normalised.length < 8) return null; // reject obviously invalid inputs early
      const hash = await _hashRecoveryCode(normalised);
      const storedHash = gget(_KEY_RECOVERY_HASH, '');
      if (!storedHash || hash !== storedHash) return null;
      const saltB64 = gget(_KEY_RECOVERY_SALT, '');
      const blob    = gget(_KEY_RECOVERY_ENC,  '');
      if (!saltB64 || !blob) return null;
      const saltArr = new Uint8Array(atob(saltB64).split('').map(c => c.charCodeAt(0)));
      const ck = await _pinDeriveKey(normalised, saltArr);
      const raw   = atob(blob);
      const bytes = new Uint8Array(raw.length).map((_, i) => raw.charCodeAt(i));
      const iv    = bytes.slice(0, 12);
      const ct    = bytes.slice(12);
      const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, ck, ct);
      return new TextDecoder().decode(plain);
    } catch { return null; }
  }

  // Clears all recovery artefacts (called on PIN remove).
  function _clearRecoveryBackup() {
    try { GM_deleteValue(_KEY_RECOVERY_HASH); } catch {}
    try { GM_deleteValue(_KEY_RECOVERY_ENC);  } catch {}
    try { GM_deleteValue(_KEY_RECOVERY_SALT); } catch {}
  }

  // ─── EXPORT / IMPORT ───────────────────────────────────────────────────────
  
  
  const _EXPORT_KEYS = [
    // ── API key (enc blob + salt stored via constants = real key strings below) ──
    'ms2_apiKey',         // plain text key (no PIN)
    'ms2_apiKey_enc',     // AES-GCM encrypted blob (PIN active)
    'ms2_apiKey_salt',    // PBKDF2 salt for PIN
    // ── PIN recovery code backup ────────────────────────────────────────────────
    // BUG FIX: these three were missing from this list. That meant export/import
    // silently dropped the recovery backup — anyone restoring onto a new browser
    // kept their recovery code but it no longer worked against anything, because
    // the hash/blob/salt it's checked against never made it into the backup file.
    'jv5_pin_recovery_hash',  // hex SHA-256 verifier for the recovery code
    'jv5_pin_recovery_enc',   // API key re-encrypted under PBKDF2(recovery code)
    'jv5_pin_recovery_salt',  // PBKDF2 salt for the recovery blob
    // ── Provider / model ──────────────────────────────────────────────────────
    'ms2_endpoint', 'ms2_model', 'ms2_authMode',
    // ── UI layout ─────────────────────────────────────────────────────────────
    'ms2_fabRight', 'ms2_fabBottom',
    // ── Reply / generation behaviour ──────────────────────────────────────────
    'ms2_defaultTone', 'ms2_defaultInstruct',
    'ms2_autoNotify', 'ms2_shortenLength', 'ms2_keepDialogue',
    'ms2_asum_auto', 'ms2_asum_every',   // auto-summarise settings
    'ms2_autoload_global',                // global auto-load toggle
    'ms2_inject_ctx',                     // inject-context toggle
    // ── System-prompt presets ─────────────────────────────────────────────────
    'ms2_activePreset', 'ms2_presets',
    // ── Persona library ───────────────────────────────────────────────────────
    'ms2_persona_lib',
    // ── Global memory / notes ─────────────────────────────────────────────────
    'ms2_global_memory',
    // ── Advanced prompt (AP) module ───────────────────────────────────────────
    'ap_enabled', 'ap_selected', 'ap_presets',
    'ap_forbidden_words', 'ap_thinking', 'ap_semantic_ban',
    // ── PIN settings ──────────────────────────────────────────────────────────
    'jv5_pin_idle_min',
    // ── Community chat / crypto ───────────────────────────────────────────────
    // jv5_psk_b exported only when no PIN (plain). With PIN it is re-derived
    // on unlock so exporting the ciphertext blob is pointless.
    'jv5_psk_b',              // plain PSK half-B (no PIN); ciphertext if PIN — import handles both
    'jv5_custom_psk_hash',    // verifier used to detect custom PSK on fresh install
    'jv5_custom_psk_set',     // boolean flag: user has a custom PSK
    'jv5_use_argon2', 'jv5_use_ed25519_admin',
    'jv4_p2p_admin_hash',     // P2P admin password hash — losing this strips admin in your rooms
    // ── Selector / network ────────────────────────────────────────────────────
    'jv5_selector_remote_url',
    'jv5_verified_url',
    // ── Misc ──────────────────────────────────────────────────────────────────
    'jv5_storage_version',
  ];

  async function _exportSettings() {
    const hasPIN   = _pinIsActive();
    const unlocked = _pinSession.active;
    const hasPlain = !!gget(_KEY_PLAIN_GM, '');

    // Warn if API key is plain text in the backup
    if (!hasPIN && hasPlain) {
      const ok = await new Promise(resolve => {
        const ov = document.createElement('div');
        ov.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;';
        ov.innerHTML = `
          <div style="background:#13131f;border:1px solid rgba(251,191,36,0.4);border-radius:14px;
            padding:20px 18px;width:min(340px,calc(100vw-32px));font-family:system-ui,sans-serif;">
            <div style="font-size:13px;font-weight:700;color:#fbbf24;margin-bottom:8px;">⚠️ API Key in Plain Text</div>
            <div style="font-size:12px;color:#d1d5db;line-height:1.6;margin-bottom:14px;">
              Your API key has no PIN protection, so it will appear in plain text inside the export file.
              Anyone with the file can read and use your key.<br><br>
              Consider setting a PIN before exporting, or keep the file somewhere safe.
            </div>
            <div style="display:flex;gap:8px;">
              <button id="_ex_ok" style="flex:1;padding:9px;font-size:12px;font-weight:600;
                background:rgba(251,191,36,0.15);border:1px solid rgba(251,191,36,0.4);
                border-radius:8px;color:#fbbf24;cursor:pointer;">Export anyway</button>
              <button id="_ex_cancel" style="flex:1;padding:9px;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);
        ov.querySelector('#_ex_ok').onclick     = () => { ov.remove(); resolve(true);  };
        ov.querySelector('#_ex_cancel').onclick = () => { ov.remove(); resolve(false); };
        ov.onclick = e => { if (e.target === ov) { ov.remove(); resolve(false); } };
      });
      if (!ok) return;
    }

    const out = {
      _jv5_export_version: 1,
      _exported_at: new Date().toISOString(),
      _has_pin: hasPIN,
      _api_key_encrypted: hasPIN,
      settings: {},
    };

    for (const key of _EXPORT_KEYS) {
      try {
        const v = GM_getValue(key, undefined);
        if (v !== undefined) out.settings[key] = v;
      } catch {}
    }

    const json = JSON.stringify(out, 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-backup-${new Date().toISOString().slice(0,10)}.json`;
    a.click();
    URL.revokeObjectURL(url);
    toastHTML(`${SVG_SHIELD} Settings exported`);
  }

  async function _importSettings() {
    const input = document.createElement('input');
    input.type  = 'file';
    input.accept = '.json,application/json';
    input.onchange = async () => {
      const file = input.files[0];
      if (!file) return;
      try {
        const text = await file.text();
        // _safeJSONParse guards against prototype pollution from a crafted backup file.
        const data = _safeJSONParse(text);
        if (!data._jv5_export_version || !data.settings) {
          toastHTML('⚠️ Not a valid JanitorV5 backup file.'); return;
        }
        let imported = 0;
        for (const [key, val] of Object.entries(data.settings)) {
          // Only restore keys we know about — never blindly restore arbitrary keys
          if (_EXPORT_KEYS.includes(key)) {
            try { GM_setValue(key, val); imported++; } catch {}
          }
        }
        // Bust the cfg cache so changes take effect immediately
        for (const key of Object.keys(data.settings)) delete _cfgCache[key];
        _apPresetsCache = null;

        const hadPin = data._has_pin;
        const msg = hadPin
          ? `${SVG_SHIELD} ${imported} settings restored. PIN-encrypted key imported — enter your original PIN to unlock.`
          : `${SVG_SHIELD} ${imported} settings restored.`;
        toastHTML(msg, 6000);
        // Reload the settings panel if open so the restored values show
        const openModal = document.querySelector('.ms2-settings-v2');
        if (openModal) {
          openModal.remove();
          setTimeout(() => openSettingsModal('general'), 200);
        }
      } catch (e) {
        toastHTML('⚠️ Import failed — file may be corrupt or invalid.');
        _devWarn('[JanitorV5] Import error:', e);
      }
    };
    input.click();
  }

  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 {}

      // ── PSK half B restore (v5.10.0 hardening) ──────────────────────────────
      // When PIN is removed, decrypt jv5_psk_b_enc and write it back to plaintext
      // jv5_psk_b so Community Chat continues to work without PIN.
      try {
        const _pskEncBlob64 = (() => { try { return GM_getValue(_pskGmKeyEnc, ''); } catch { return ''; } })();
        if (_pskEncBlob64 && _pinSession.cryptoKey) {
          const pskBlob  = _b64ToArr(_pskEncBlob64);
          const pskIv    = pskBlob.slice(0, 12);
          const pskCt    = pskBlob.slice(12);
          const pskPln   = await crypto.subtle.decrypt(
            { name: 'AES-GCM', iv: pskIv }, _pinSession.cryptoKey, pskCt);
          const halfB    = new TextDecoder().decode(pskPln);
          try { GM_setValue(_pskGmKey, halfB); } catch {}
        }
        try { GM_deleteValue(_pskGmKeyEnc); } catch {} // always clean up encrypted copy
      } catch { /* non-fatal — worst case PSK half stays encrypted */ }

      _pinSession.active = false; _pinSession.cryptoKey = null; _sessionApiKey = ''; _sessionEndpointBinding = '';
      setTimeout(_syncFabSecBadge, 0); // re-add warning badge now that PIN is gone
      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).
    // ─── 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;

  // Gets the idle-lock timeout in minutes — returns 0 if disabled or not set.
  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();
  }

  
  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 600,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>`,
    },

    'export-import': {
      title: '💾 Backup & Restore',
      tags: [{ text: 'Settings', cls: '' }],
      body: `
        <p><strong>Export</strong> saves all your settings, presets, personas, and style configurations as a JSON file you can keep anywhere.</p>
        <hr class="jv5-info-divider">
        <p><strong>With PIN set:</strong> your API key is exported as an encrypted blob. The file is safe to store in cloud storage — it's useless without your PIN.</p>
        <p><strong>Without PIN:</strong> your API key is plain text in the file. The export warns you first and you can cancel to set a PIN first.</p>
        <hr class="jv5-info-divider">
        <p><strong>Import</strong> merges the backup into your current installation — it won't wipe settings that aren't in the file. If the backup had a PIN-encrypted key, you'll need to enter your original PIN to unlock it after import.</p>
        <p>This is also the recovery path if you accidentally delete the script from Tampermonkey — reinstall and import your backup.</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+PCS</strong> — Each peer gets their own ECDH-bootstrapped chain that advances on every message (old message keys are deleted after use) AND periodically re-keys itself via a fresh DH exchange (a "DH ratchet step"). The chain advancement alone means a captured PSK can't decrypt past traffic (forward secrecy). The periodic re-keying goes further: even if your <em>entire current ratchet state</em> were somehow captured, post-compromise messages remain secret once one more re-key completes (post-compromise security). Falls back automatically to chain-advancement-only if the peer is on an older JV5 version.</p>
        <p><strong>PBKDF2-SHA-512 KDF</strong> — Uses PBKDF2-SHA-512 at 600,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>JV5 Community Chat uses a two-half PSK (pre-shared key) for baseline encryption:</p>
        <ul style="margin:6px 0 8px;padding-left:18px;">
          <li><strong>Source half</strong> — a fixed value baked into the script, the same for every install. This is not a secret; it just seeds the derivation.</li>
          <li><strong>GM half</strong> — a random 32-byte value generated on your first install and stored only in Tampermonkey's private GM storage. It never leaves your device automatically.</li>
        </ul>
        <p>The final PSK is <code>SHA-256(srcHalf : gmHalf : "psk-derive")</code>. Because the GM half differs per install, two fresh installs derive <strong>different keys</strong> — which means the global room requires sharing your GM half out-of-band (e.g. via Export/Import) for two users to read each other's messages by default.</p>
        <p>Setting a <strong>custom passphrase</strong> creates a <em>private sub-group</em> instead: both users set the same passphrase, which is mixed into the derivation, and everyone else sees ciphertext 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 default GM-half derivation.</p>
        <p><strong>Tip:</strong> Share your passphrase via a private channel (Discord DM, Signal) — never in the JV5 chat room 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-bootstrapped chain that advances every message and periodically re-keys itself via a new DH exchange. Past messages stay secret even if your current key is exposed, and — once one more re-key completes after a compromise — future messages do too.</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 ─────────────────────────────────────────────────────
  
  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(_safeJSONParse(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.

  
  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 stable CSS selector from the winning element.
      //
      // IMPORTANT — do NOT use positional/index attributes (data-index,
      // aria-rowindex, data-row, etc.) here. Virtualised lists re-use and
      // re-number those attributes as the user scrolls, so an index-specific
      // selector like div[data-index="3"] points to a *different* message
      // within seconds of being generated. Prefer IDs, test-IDs, or
      // semantic class names that remain stable across renders.
      if (el.id) return `#${el.id}`;
      if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
      // Semantic class names are stable across re-renders; fall back to a
      // type-only structural match that at least scopes to message containers.
      const cls = Array.from(el.classList).find(c => c.includes('message') || c.includes('item'));
      return cls ? `div.${CSS.escape(cls)}` : 'div[data-testid]';
    }

    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();

      // ⚠️  SECURITY WARNING — shown every time a remote selector URL is active.
      //
      // A remote URL lets an external server control which CSS selectors this
      // script uses to read and manipulate the page. If an attacker tricks you
      // into pointing this at their server (social engineering, URL swap, etc.)
      // they can silently redirect DOM reads — potentially exposing message
      // content or UI state.
      //
      // Only set jv5_selector_remote_url to a URL you personally control.
      // The allowlist below limits what keys can be overridden, but it does
      // NOT make arbitrary remote URLs safe.
      console.warn(
        '[JanitorV5] ⚠️  Remote selector URL is active:', this.remoteUrl,
        '\nOnly use a URL you personally control. ' +
        'To disable: open your userscript manager storage and delete jv5_selector_remote_url.'
      );
      // Surface a non-dismissible one-time toast in the page UI as well so the
      // warning is visible even to users who never open DevTools.
      if (!this._remoteUrlWarnShown) {
        this._remoteUrlWarnShown = true;
        // Use setTimeout to ensure toast infrastructure is ready after init.
        setTimeout(() => {
          try {
            toastHTML(
              `<span style="color:#fbbf24;font-weight:700;">⚠️ Remote selector URL active</span>` +
              `<br><span style="font-size:11px;opacity:.85;">` +
              `A remote URL is controlling CSS selectors. Only use a URL you personally control. ` +
              `Delete <code>jv5_selector_remote_url</code> from GM storage to disable.</span>`,
              7000
            );
          } catch (_) { /* toast not yet available — console warning above is sufficient */ }
        }, 2000);
      }

      try {
        GM_xmlhttpRequest({
          method: 'GET', url: this.remoteUrl, timeout: 8000,
          onload: (r) => {
            try {
              const map = _safeJSONParse(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) ─────────────────────────────
  
  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) ────────────────
  
  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)');
    }

    // ── unsafeWindow patch-integrity check ────────────────────────────────
    // unsafeWindow is shared by ALL page-context scripts and every other
    // installed userscript with @grant unsafeWindow. Any of them can patch
    // window.fetch/XMLHttpRequest again AFTER JV5 does — there's no way to
    // fully prevent this short of Object.defineProperty-locking these
    // globals, which risks breaking other tools the user relies on. Instead
    // this provides DETECTION: if something else has since replaced our
    // patched function, isIntact() returns false so the diagnostic can warn
    // the user that network monitoring (circuit breaker) may be bypassed —
    // visibility without an aggressive, compatibility-risking lockdown.
    isIntact() {
      try {
        return unsafeWindow.fetch === this.patchedFetch &&
               unsafeWindow.XMLHttpRequest === this.patchedXHR;
      } catch { return null; }
    }

    _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;

      // Arrow function captures `this` (the NetworkInterceptor) lexically —
      // no `self` alias needed. `fetch` does not depend on its call-site `this`.
      unsafeWindow.fetch = async (...args) => {
        if (this._shouldCircuitBreak()) {
          return Promise.reject(new Error('Network circuit breaker open'));
        }
        try {
          const response = await this.originalFetch(...args);
          this._recordSuccess();
          return response;
        } catch (err) {
          this._recordFailure();
          throw err;
        }
      };
      this.patchedFetch = unsafeWindow.fetch;
    }

    _patchXHR() {
      if (typeof unsafeWindow === 'undefined' || this.originalXHR) return;
      this.originalXHR = unsafeWindow.XMLHttpRequest;
      // `interceptor` is an explicit alias for the NetworkInterceptor instance.
      // Inside the constructor function below, `this` refers to the XHR object
      // being constructed, so we need a separate reference to the interceptor.
      // Named `interceptor` (not `self`) to avoid shadowing the global `window.self`.
      const interceptor = this;

      unsafeWindow.XMLHttpRequest = function () {
        const xhr = new interceptor.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 (interceptor._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) {
            interceptor._recordSuccess();
            if (origOnLoad) return origOnLoad.apply(this, loadArgs);
          };

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

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

        return xhr;
      };
      this.patchedXHR = unsafeWindow.XMLHttpRequest;
    }

    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.
  // Frozen-by-convention (Set can't be Object.frozen) — do not mutate
  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',
    'dh','pn', // v5.7.12+ Double Ratchet header fields (sender's current
               // ratchet pubkey JWK + previous-chain length) — required for
               // ratchetDecrypt's v:3 path; both are plain JSON values
               // (object/number) consumed read-only by _jwkFp/_kdfRk/etc.
  ]);
  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),
  // 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'
  );

  // ── SSRF: numeric-IPv4-literal parsing ──────────────────────────────────
  // The regex above only catches STANDARD dotted-decimal notation. Browsers
  // (and GM_xmlhttpRequest's underlying fetch/XHR) also accept decimal
  // ("http://2130706433/"), hex ("http://0x7f000001/"), octal
  // ("http://0177.0.0.1/"), and shorthand ("http://127.1/") IPv4 forms, all
  // of which resolve to real addresses but slip past a plain string regex.
  // This parses the hostname the same lenient way and checks the resulting
  // 32-bit value against the private/loopback/link-local/CGNAT ranges.
  function _ipv4PartsToInt(nums) {
    const k = nums.length;
    if (k < 1 || k > 4) return null;
    for (let i = 0; i < k - 1; i++) if (nums[i] < 0 || nums[i] > 255) return null;
    const lastMax = Math.pow(256, 5 - k) - 1;
    if (nums[k - 1] < 0 || nums[k - 1] > lastMax) return null;
    let value = nums[k - 1];
    let mul = Math.pow(256, 5 - k);
    for (let i = k - 2; i >= 0; i--) { value += nums[i] * mul; mul *= 256; }
    return value >>> 0;
  }
  function _octHexDecToInt(part) {
    if (/^0x[0-9a-f]+$/i.test(part)) return parseInt(part, 16);
    if (/^0[0-7]+$/.test(part))     return parseInt(part, 8);
    if (/^(0|[1-9][0-9]*)$/.test(part)) return parseInt(part, 10);
    return NaN;
  }
  function _isPrivateIPv4Int(v) {
    const b1 = (v >>> 24) & 0xff, b2 = (v >>> 16) & 0xff;
    if (b1 === 0)   return true;                    // 0.0.0.0/8
    if (b1 === 10)  return true;                    // 10.0.0.0/8
    if (b1 === 127) return true;                    // 127.0.0.0/8 loopback
    if (b1 === 169 && b2 === 254) return true;      // 169.254.0.0/16 (incl. cloud metadata)
    if (b1 === 172 && b2 >= 16 && b2 <= 31) return true; // 172.16.0.0/12
    if (b1 === 192 && b2 === 168) return true;      // 192.168.0.0/16
    if (b1 === 100 && b2 >= 64 && b2 <= 127) return true; // 100.64.0.0/10 CGNAT
    return false;
  }
  
  function _isPrivateIPv4Hostname(hostname) {
    const h = (hostname || '').toLowerCase();
    const parts = h.split('.');
    if (parts.length < 1 || parts.length > 4) return false;
    if (!parts.every(p => /^(0x[0-9a-f]+|[0-9]+)$/i.test(p))) return false;
    const nums = parts.map(_octHexDecToInt);
    if (nums.some(n => !Number.isFinite(n) || isNaN(n))) return false;
    const v = _ipv4PartsToInt(nums);
    return v !== null && _isPrivateIPv4Int(v);
  }
  
  // BUGFIX: the old code only ever ran IPv4 checks against the parsed hostname.
  // A bracketed IPv6 literal like http://[fc00::1]/ or http://[fe80::1]/ is the
  // ONLY valid URL syntax for those addresses, but _SSRF_BLOCK_RE's IPv6
  // alternatives assume no brackets (so they never match real URLs), and
  // _isPrivateIPv4Hostname can't recognize colon-separated IPv6 at all. Net
  // effect: ULA (fc00::/7) and link-local (fe80::/10) IPv6 addresses bypassed
  // the SSRF guard completely when entered in proper bracket notation. This
  // adds an explicit IPv6 check that runs whenever the hostname contains a colon.
  function _isPrivateIPv6Hostname(hostname) {
    const h = (hostname || '').toLowerCase();
    if (!h.includes(':')) return false;
    if (h === '::1' || h === '0:0:0:0:0:0:0:1' || /^0*:0*:0*:0*:0*:0*:0*:0*1$/.test(h)) return true; // loopback
    if (/^::ffff:127\./.test(h)) return true;        // IPv4-mapped loopback
    if (/^::ffff:0:127\./.test(h)) return true;       // IPv4-translated loopback
    if (/^f[cd][0-9a-f]{2}:/.test(h)) return true;    // ULA fc00::/7
    if (/^fe[89ab][0-9a-f]:/.test(h)) return true;    // link-local fe80::/10
    if (h === '::' || h === '0:0:0:0:0:0:0:0') return true; // unspecified
    return false;
  }

  function _isSSRFBlocked(urlStr) {
    const s = String(urlStr || '');
    if (_SSRF_BLOCK_RE.test(s)) return true;
    try {
      const u = new URL(s);
      if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
      let host = u.hostname;
      if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); // IPv6 brackets
      if (host === 'localhost' || host.endsWith('.localhost')) return true;
      if (_isPrivateIPv4Hostname(host)) return true;
      if (_isPrivateIPv6Hostname(host)) return true;
      return false;
    } catch {
      return true; // can't parse → fail closed
    }
  }

  // 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) && !_isSSRFBlocked(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 ''; }
  }

  
  function _p2pRelayHeaders(extra = {}) {
    const headers = { ...extra };
    try {
      const relay = _p2pGetRelay();
      const token = _p2pGetRelayToken();
      if (token && relay !== P2P_RELAYS[0]) {
        headers['Authorization'] = 'Bearer ' + _sanitizeHeaderVal(token);
      }
    } catch {}
    return headers;
  }

  // ─── 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);
  })();
  const P2P_TOPIC_REPORTS= 'jv4-reports-v1';

  // 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 ───────────────────────────────────────────────────────
  
    // ─── 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 ──────────────────────────────────────────
    // FULL Signal-protocol-style Double Ratchet (symmetric-key ratchet +
    // DH ratchet), v5.7.12+:
    //   Root key → (DH ratchet step) → chain key → (symmetric ratchet) →
    //   per-message keys (HKDF-SHA-256 / HMAC-SHA-256).
    //
    // SYMMETRIC RATCHET (unchanged from earlier versions): each message
    // advances its chain via _advanceChain(); old message keys are deleted
    // immediately, so a captured ciphertext cannot be decrypted even if the
    // chain state is later compromised, UNLESS the attacker captured that
    // exact chain state BEFORE the message was processed (forward secrecy
    // within a chain).
    //
    // DH RATCHET (new in v5.7.12): periodically, each side generates a fresh
    // ECDH keypair (DHs) and includes its public half in the message header
    // (`dh`). When a peer observes a NEW `dh` from the other side (different
    // from the `DHr` they have on file), BOTH the receive chain (derived from
    // DH(our current DHs, their new DHr)) and a freshly-generated send chain
    // (derived from DH(our NEW DHs, their new DHr)) are re-derived from the
    // root key via HKDF (KDF_RK). Because ECDH is symmetric — DH(a, B) ==
    // DH(b, A) for keypairs (a,A) and (b,B) — both sides converge on the same
    // new root/chain keys without any further coordination.
    //
    // This provides POST-COMPROMISE SECURITY: even if an attacker captures
    // the complete ratchet state (rootKey + both chains + DHs/DHr) at some
    // instant, as soon as either side performs ONE MORE DH ratchet step using
    // a freshly-generated keypair the attacker doesn't have, all subsequent
    // keys are unrecoverable to that attacker (their captured rootKey is
    // mixed with a DH output they cannot compute).
    //
    // BOOTSTRAP & ROTATION TRIGGER: both sides start with DHs == their own
    // session ephemeral key, and DHr == the OTHER side's session ephemeral
    // key (exchanged during the ratchet-hello/ratchet-reply handshake). If
    // neither side ever generated a NEW DHs, `dh` headers would never change
    // and no DH ratchet step would ever fire (this was the v5.7.10 behavior —
    // symmetric ratchet only). To bootstrap continuous re-keying, EACH side
    // independently performs a PROACTIVE rotation (_maybeProactiveRotate)
    // every _DR_ROTATE_EVERY messages it sends: it generates a new DHs and
    // re-derives its send chain via DH(new DHs, current DHr). The next time
    // the peer receives a message, they see a `dh` that differs from their
    // stored DHr, triggering their own _dhRatchetStep (which ALSO generates
    // a fresh DHs on their side) — so re-keying continues to ping-pong for as
    // long as the conversation continues.
    //
    // OUT-OF-ORDER / SKIPPED MESSAGES: ntfy delivery order isn't strictly
    // guaranteed, and messages can be dropped/retried. _skipMessageKeys()
    // derives-and-stores message keys for any chain indices that are skipped
    // over (either within the current chain, or while draining the OLD chain
    // across a DH-ratchet boundary using the sender's `pn` header — the
    // length of their previous sending chain). Stored skipped keys are capped
    // (_DR_MAX_SKIPPED_TOTAL) and expire (_DR_SKIP_TTL_MS) to bound memory; a
    // single skip request larger than _DR_MAX_SKIP is refused outright as a
    // DoS guard against a malicious/corrupted `n`/`pn`.
    //
    // VERSION NEGOTIATION: the ratchet-hello/ratchet-reply handshake messages
    // carry `dr: 2`. A per-peer session only enables the full DH ratchet
    // (`state.dr2 = true`, output `v:3` with `dh`/`pn` headers) if the PEER
    // also advertised `dr:2`. Otherwise the session falls back byte-for-byte
    // to the v5.7.10 symmetric-only `v:2` format — a peer running an older
    // JV5 (no `dr` field, no `dh`/`pn` handling) continues to work exactly as
    // before, just without the post-compromise-security property.
    _DR_ROTATE_EVERY:      20,                // proactive DHs rotation every N sent messages
    _DR_MAX_SKIP:          50,                // refuse to derive more than this many keys in one skip (DoS guard)
    _DR_MAX_SKIPPED_TOTAL: 200,               // cap on stored skipped-message keys per peer
    _DR_SKIP_TTL_MS:       60 * 60 * 1000,    // evict skipped keys after 1h

    _ratchetState: new Map(), // peerId → {
      //   rootKey: Uint8Array(32)        — raw bytes, used as HKDF salt for KDF_RK
      //   DHs: { publicKey, privateKey } — our current ratchet ECDH keypair
      //   DHr: JWK                       — their current ratchet ECDH public key
      //   sendChain: CryptoKey, recvChain: CryptoKey  (HMAC-SHA-256)
      //   sendN, recvN: int              — Ns / Nr message counters in current chains
      //   prevSendN: int                 — PN, length of previous sending chain (sent in header)
      //   sendMsgsSinceRotate: int
      //   skipped: Map<"dhFp:n", {msgKey:CryptoKey, savedAt:number}>
      //   dr2: bool                      — full DH ratchet enabled for this peer (mutual support)
      // }
    _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);
    },

    // Deterministic fingerprint for a P-256 public-key JWK — its (x,y)
    // coordinates uniquely identify the point. Synchronous; used to detect
    // whether an incoming `dh` header differs from the stored DHr, and to key
    // the skipped-message-key map.
    _jwkFp(jwk) {
      return (jwk && jwk.x && jwk.y) ? `${jwk.x}.${jwk.y}` : '';
    },

    // 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.
    // `peerSupportsDR2` comes from the handshake's `dr` field — full DH
    // ratchet (v:3, dh/pn headers, proactive rotation) is enabled only if the
    // PEER also advertised dr:2; otherwise this session stays on the
    // symmetric-only v:2 format for backward compatibility.
    async initRatchetWithPeer(peerId, theirPublicKeyJwk, weAreInitiator, peerSupportsDR2 = false) {
      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']
        );
        // Root key as RAW BYTES (not a CryptoKey) — serves as the HKDF salt
        // for every future KDF_RK (DH ratchet step) call.
        const rootKeyBits = await crypto.subtle.deriveBits(
          { name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: new TextEncoder().encode('jv5-ratchet-root') },
          hkdfMaterial, 256
        );
        // 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' }, true, ['sign']
        );
        const recvChain = await crypto.subtle.deriveKey(
          { name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: recvInfo },
          hkdfMaterial,
          { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']
        );
        this._ratchetState.set(peerId, {
          rootKey: new Uint8Array(rootKeyBits),
          DHs: kp,                  // our ratchet keypair starts as our session ephemeral
          DHr: theirPublicKeyJwk,   // their ratchet pubkey starts as their session ephemeral
          sendChain, recvChain,
          sendN: 0, recvN: 0, prevSendN: 0,
          sendMsgsSinceRotate: 0,
          selfRotatePending: false,
          skipped: new Map(),
          dr2: !!peerSupportsDR2,
        });
      } 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 };
    },

    // KDF_RK — the DH-ratchet root KDF. Mixes the current root key (as HKDF
    // salt) with a new ECDH output (as HKDF ikm) to produce a new 32-byte
    // root key plus a new HMAC chain key. `label` separates the "receive
    // chain derivation" and "send chain derivation" calls within the same
    // ratchet step so they yield different outputs despite both rooting from
    // the same updated root key.
    async _kdfRk(rootKeyBytes, dhOutputBits, label) {
      const hkdfKey = await crypto.subtle.importKey('raw', dhOutputBits, { name: 'HKDF' }, false, ['deriveBits']);
      const bits = new Uint8Array(await crypto.subtle.deriveBits(
        { name: 'HKDF', hash: 'SHA-256', salt: rootKeyBytes, info: new TextEncoder().encode(label) },
        hkdfKey, 512
      ));
      const newRoot = bits.slice(0, 32);
      const chainKey = await crypto.subtle.importKey(
        'raw', bits.slice(32, 64),
        { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']
      );
      return { rootKey: newRoot, chainKey };
    },

    // Remove expired skipped-key entries and enforce the per-peer cap,
    // evicting the oldest entries first.
    _pruneSkipped(state) {
      const now = Date.now();
      for (const [k, v] of state.skipped) {
        if (now - v.savedAt > this._DR_SKIP_TTL_MS) state.skipped.delete(k);
      }
      if (state.skipped.size > this._DR_MAX_SKIPPED_TOTAL) {
        const entries = [...state.skipped.entries()].sort((a, b) => a[1].savedAt - b[1].savedAt);
        for (let i = 0; i < entries.length - this._DR_MAX_SKIPPED_TOTAL; i++) {
          state.skipped.delete(entries[i][0]);
        }
      }
    },

    // Derive-and-store message keys for chain indices [state.recvN, until)
    // under the chain associated with `dhJwk`'s fingerprint, advancing
    // state.recvChain each step. Used both for ordinary out-of-order
    // tolerance within the current chain, and — with `dhJwk` = the OLD DHr
    // and `until` = the sender's `pn` — to drain any messages still in flight
    // from the OLD chain across a DH-ratchet boundary so they remain
    // decryptable if/when they arrive late.
    async _skipMessageKeys(state, dhJwk, until) {
      if (until == null || until <= state.recvN) return;
      const n = until - state.recvN;
      if (n > this._DR_MAX_SKIP) throw new Error('ratchet-skip-too-large');
      const fp = this._jwkFp(dhJwk);
      for (let i = state.recvN; i < until; i++) {
        const { msgKey, newChain } = await this._advanceChain(state.recvChain);
        state.recvChain = newChain;
        state.skipped.set(`${fp}:${i}`, { msgKey, savedAt: Date.now() });
      }
      state.recvN = until;
      this._pruneSkipped(state);
    },

    // DH ratchet step, triggered on RECEIVE when an incoming `dh` header
    // differs from state.DHr (the peer has rotated their ratchet key).
    // Re-derives the receive chain from DH(our current DHs, their new DHr),
    // then generates a fresh DHs for ourselves and re-derives the send chain
    // from DH(our new DHs, their new DHr). ECDH's symmetry — DH(a,B) ==
    // DH(b,A) — guarantees this converges with the peer's independent
    // _dhRatchetStep / _maybeProactiveRotate on their side.
    async _dhRatchetStep(state, newPeerDHPubJwk) {
      const newPeerPub = await crypto.subtle.importKey(
        'jwk', newPeerDHPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []
      );

      // Receive-chain re-derivation using our EXISTING DHs.
      const dhOutRecv = await crypto.subtle.deriveBits(
        { name: 'ECDH', public: newPeerPub }, state.DHs.privateKey, 256
      );
      const recvDerived = await this._kdfRk(state.rootKey, dhOutRecv, 'jv5-dh-step');
      state.rootKey  = recvDerived.rootKey;
      state.recvChain = recvDerived.chainKey;
      state.recvN = 0;
      state.DHr = newPeerDHPubJwk;
      // The peer's `dh` changed, which only happens via THEIR _dhRatchetStep
      // — i.e. they processed a message carrying OUR previous rotation's
      // `dh`. That's the acknowledgment that lets us safely rotate again.
      state.selfRotatePending = false;

      // Send-chain re-derivation using a FRESH DHs.
      const newDHs = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
      const dhOutSend = await crypto.subtle.deriveBits(
        { name: 'ECDH', public: newPeerPub }, newDHs.privateKey, 256
      );
      const sendDerived = await this._kdfRk(state.rootKey, dhOutSend, 'jv5-dh-step');
      state.rootKey   = sendDerived.rootKey;
      state.sendChain = sendDerived.chainKey;
      state.prevSendN = state.sendN;
      state.sendN = 0;
      state.DHs = newDHs;
      state.sendMsgsSinceRotate = 0;
    },

    // Proactive rotation: every _DR_ROTATE_EVERY messages WE send, generate a
    // new DHs and re-derive our send chain via DH(new DHs, current DHr). This
    // is what BOOTSTRAPS the ping-pong DH ratchet (see header comment) — the
    // peer will see our new `dh` header differ from their stored DHr and run
    // their own _dhRatchetStep in response.
    //
    // CORRECTNESS CONSTRAINT: a receiver's _dhRatchetStep can only bridge ONE
    // DH generation in a single _kdfRk step (it has no way to recover an
    // intermediate root key for a generation it never saw a `dh` for). So we
    // must never have more than ONE unacknowledged rotation outstanding —
    // `selfRotatePending` blocks further rotation until the peer's `dh`
    // changes (proof they processed a message carrying our pending rotation's
    // `dh` and advanced in lock-step). In a one-sided monologue where the
    // peer never responds, this means rotation effectively pauses after the
    // first one — still strictly better than v5.7.10 (zero rotations), and
    // resumes as soon as the peer sends anything that completes the
    // handshake-style exchange.
    async _maybeProactiveRotate(state) {
      if (!state.dr2 || state.selfRotatePending || state.sendMsgsSinceRotate < this._DR_ROTATE_EVERY) return;
      try {
        const peerPub = await crypto.subtle.importKey(
          'jwk', state.DHr, { name: 'ECDH', namedCurve: 'P-256' }, false, []
        );
        const newDHs = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
        const dhOut = await crypto.subtle.deriveBits(
          { name: 'ECDH', public: peerPub }, newDHs.privateKey, 256
        );
        const derived = await this._kdfRk(state.rootKey, dhOut, 'jv5-dh-step');
        state.rootKey   = derived.rootKey;
        state.sendChain = derived.chainKey;
        state.prevSendN = state.sendN;
        state.sendN = 0;
        state.DHs = newDHs;
        state.sendMsgsSinceRotate = 0;
        state.selfRotatePending = true;
      } catch (e) {
        _devWarn('[Ratchet] Proactive rotation failed:', e.message);
      }
    },

    async ratchetEncrypt(plainObj, peerId) {
      const state = this._ratchetState.get(peerId);
      if (!state) return null; // no ratchet session — caller falls back to PSK
      try {
        await this._maybeProactiveRotate(state);
        const { msgKey, newChain } = await this._advanceChain(state.sendChain);
        state.sendChain = newChain;
        const n = state.sendN++;
        state.sendMsgsSinceRotate++;
        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);
        const out = {
          encrypted: true, ratchet: true, peerId, n,
          iv: Array.from(iv),
          ciphertext: Array.from(new Uint8Array(ct)),
          v: state.dr2 ? 3 : 2,
        };
        if (state.dr2) {
          // Header for the full Double Ratchet: our current ratchet public
          // key (so the peer can detect rotation) and the length of our
          // previous sending chain (so they know how many keys to drain from
          // the OLD chain for any still-in-flight messages from before a
          // rotation we already performed).
          out.pn = state.prevSendN;
          out.dh = await crypto.subtle.exportKey('jwk', state.DHs.publicKey);
        }
        return out;
      } catch (e) {
        _devWarn('[Ratchet] Encrypt failed:', e.message);
        return null;
      }
    },

    // Shallow-clone mutable per-peer ratchet state before attempting a v3
    // DH-ratchet step + decrypt. CryptoKey objects are opaque/immutable
    // handles — _dhRatchetStep/_advanceChain always REPLACE them with new
    // objects rather than mutating in place, so sharing references between
    // `state` and the clone is safe. `rootKey` (Uint8Array) and `skipped`
    // (Map) get real copies since those ARE mutated in place.
    _cloneState(state) {
      return {
        rootKey: state.rootKey.slice(),
        DHs: state.DHs, DHr: state.DHr,
        sendChain: state.sendChain, recvChain: state.recvChain,
        sendN: state.sendN, recvN: state.recvN, prevSendN: state.prevSendN,
        sendMsgsSinceRotate: state.sendMsgsSinceRotate,
        selfRotatePending: state.selfRotatePending,
        skipped: new Map(state.skipped),
        dr2: state.dr2,
      };
    },
    _commitState(state, work) {
      state.rootKey = work.rootKey;
      state.DHs = work.DHs; state.DHr = work.DHr;
      state.sendChain = work.sendChain; state.recvChain = work.recvChain;
      state.sendN = work.sendN; state.recvN = work.recvN; state.prevSendN = work.prevSendN;
      state.sendMsgsSinceRotate = work.sendMsgsSinceRotate;
      state.selfRotatePending = work.selfRotatePending;
      state.skipped = work.skipped;
    },

    async ratchetDecrypt(encObj, fromPeerId) {
      const state = this._ratchetState.get(fromPeerId);
      if (!state || !encObj.ratchet) return null;
      try {
        let msgKey;
        let work = state;
        let usedClone = false;
        if (state.dr2 && encObj.v === 3 && encObj.dh) {
          // ── Full Double Ratchet path ──────────────────────────────────────
          const skipKey = `${this._jwkFp(encObj.dh)}:${encObj.n}`;
          const stored = state.skipped.get(skipKey);
          if (stored) {
            msgKey = stored.msgKey;
            state.skipped.delete(skipKey);
          } else {
            // Everything below MUTATES ratchet state (rootKey, chains, DHs/DHr,
            // counters, skipped-key cache). If `encObj.dh` turns out to be more
            // than one generation away (can't normally happen given
            // selfRotatePending's "at most one outstanding rotation" guarantee,
            // but could from a malformed/adversarial message, a desynced peer,
            // or state lost on one side), the derived msgKey simply won't match
            // and AES-GCM decrypt below will throw. Operate on a CLONE so that
            // failure leaves `state` completely untouched — one bad message
            // can't permanently break all future ones.
            work = this._cloneState(state);
            usedClone = true;
            const dhChanged = this._jwkFp(encObj.dh) !== this._jwkFp(work.DHr);
            if (dhChanged) {
              // Drain any messages still in flight on the OLD chain (sent
              // before the peer's rotation) so late arrivals stay decryptable.
              await this._skipMessageKeys(work, work.DHr, encObj.pn ?? work.recvN);
              await this._dhRatchetStep(work, encObj.dh);
            }
            // Skip ahead to encObj.n within the (possibly just-rotated) chain,
            // then derive n's key as the next step.
            await this._skipMessageKeys(work, work.DHr, encObj.n);
            const adv = await this._advanceChain(work.recvChain);
            work.recvChain = adv.newChain;
            msgKey = adv.msgKey;
            work.recvN = encObj.n + 1;
          }
        } else {
          // ── Legacy v2 symmetric-only path (peer doesn't support dr2, or
          //    this message predates the DH-ratchet header fields) ──────────
          const adv = await this._advanceChain(state.recvChain);
          state.recvChain = adv.newChain;
          msgKey = adv.msgKey;
        }
        const iv = new Uint8Array(encObj.iv);
        const ct = new Uint8Array(encObj.ciphertext);
        const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, msgKey, ct);
        // Decrypt succeeded — commit any cloned ratchet-step mutations now.
        // (A "stale timestamp" rejection below still commits: the sender's
        // chain DID advance when they sent it, so our receive chain must
        // advance too or we'll desync on the NEXT, non-stale message —
        // mirrors the pre-existing v2 behavior, which mutates `state` directly
        // before the staleness check.)
        if (usedClone) this._commitState(state, work);
        // _safeJSONParse: peer message payload is untrusted network data — guard
        // against prototype pollution from a malicious or compromised peer.
        const plain = _safeJSONParse(_unpadMessage(new Uint8Array(dec)));
        if (!plain || typeof plain !== 'object') return null; // malformed payload
        const now = Date.now();
        if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 60 * 6) return null; // stale
        delete plain._rn; delete plain._ts;
        return plain;
      } catch (e) {
        _devWarn('[Ratchet] Decrypt failed:', e.message);
        return null; // `state` untouched if a clone was in use — safe to retry/continue
      }
    },

    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);
      const cacheKey = `${roomId}:${password}:${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']
      );

      const salt = enc.encode('jv5-p2p-' + roomId + (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: _PBKDF2_ITERS, hash: _PBKDF2_HASH },
          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;
        }
        // _safeJSONParse: network data — guard against prototype pollution.
        const plain = _safeJSONParse(unpadded);
        if (!plain || typeof plain !== 'object') {
          console.warn('[P2PCrypto] Malformed payload after decrypt');
          return null;
        }
        const now = Date.now();
        if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 60 * 6) {
          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 (plaintext), or from encrypted GM copy (PIN-protected),
  // or generate fresh on first install.
  //
  // SECURITY UPGRADE (v5.10.0): when a PIN is active, jv5_psk_b is encrypted under
  // the same PIN-derived AES-GCM key as the API key and stored as jv5_psk_b_enc.
  // The plaintext copy is deleted. This prevents a rogue co-installed Tampermonkey
  // script from reading jv5_psk_b directly and reconstructing the full PSK to decrypt
  // Community Chat messages from the ntfy.sh relay.
  const _pskGmKey    = 'jv5_psk_b';
  const _pskGmKeyEnc = 'jv5_psk_b_enc'; // AES-GCM-encrypted copy (active when PIN is set)
  let _storedHalfB = (() => {
    try {
      const plain = GM_getValue(_pskGmKey, null);
      if (plain) return plain;
      // Plaintext absent — encrypted copy may exist (PIN was set). Return null;
      // _storedHalfB will be populated in _pinDecryptKey() after PIN unlock.
    } catch {}
    return null;
  })();
  if (!_storedHalfB) {
    // Generate a fresh half ONLY if neither plaintext nor encrypted copy exists.
    // If the encrypted copy exists, we're simply waiting for the user to enter their PIN.
    const _encCopyExists = (() => { try { return !!GM_getValue(_pskGmKeyEnc, null); } catch { return false; } })();
    if (!_encCopyExists) {
      _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')
  // When _storedHalfB is null (encrypted, awaiting PIN), P2P_PSK is null.
  // Community Chat functions guard against null PSK — features are unavailable
  // until the user enters their PIN, at which point _rederivePsk() is called.
  let P2P_PSK = _storedHalfB ? 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('');
  })() : null;
  const P2P_PSK_ROOM = '__psk__'; // synthetic room ID so PSK key != per-room key

  
  // ── 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
  let _activePskCache = null; // { hash: string, psk: string } — invalidated when customHash changes

  // Re-derives P2P_PSK from _storedHalfB and busts the custom-PSK HKDF cache.
  // Called after PIN unlock when jv5_psk_b_enc is successfully decrypted.
  async function _rederivePsk() {
    if (!_storedHalfB) return;
    const combined = _pskSrc + ':' + _storedHalfB + ':psk-derive';
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(combined));
    P2P_PSK = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
    _activePskCache = null; // invalidate custom-PSK HKDF cache so it re-derives with new base
  }

  async function _getActivePsk() {
    // PSK is null when jv5_psk_b is PIN-encrypted and the user hasn't unlocked yet.
    if (!P2P_PSK) return null;
    const customHash = (() => { try { return GM_getValue('jv5_custom_psk_hash', ''); } catch { return ''; } })();
    if (customHash && customHash.length === 64) {
      // Cache hit — avoid re-running HKDF on every message
      if (_activePskCache && _activePskCache.hash === customHash) return _activePskCache.psk;
      // HKDF: IKM = customHash bytes, salt = PSK bytes, info = domain tag
      const enc = new TextEncoder();
      const ikm = await crypto.subtle.importKey('raw', enc.encode(customHash), { name: 'HKDF' }, false, ['deriveKey']);
      const derived = 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', derived);
      const hex = Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2, '0')).join('');
      _activePskCache = { hash: customHash, psk: hex };
      return hex;
    }
    _activePskCache = null;
    return P2P_PSK;
  }

  async function pskEncrypt(plainObj) {
    try {
      const activePsk = await _getActivePsk();
      if (!activePsk) {
        // PSK is PIN-locked — Community Chat requires PIN unlock to send messages.
        console.warn('[JV5-PSK] Encrypt skipped: PSK locked — enter PIN to enable Community Chat');
        return null;
      }
      return await P2PCrypto.encrypt(plainObj, activePsk, P2P_PSK_ROOM);
    } catch (e) {
      console.warn('[JV5-PSK] Encrypt failed:', e.message);
      return null;
    }
  }

  
  async function pskDecrypt(encObj) {
    if (!encObj || !encObj.encrypted) return null;
    if (!P2P_PSK) return null; // PSK locked — cannot decrypt until PIN is entered
    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;
    }
  }

  // Quick check — does this message envelope look 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.

  
  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 {
          const data = _safeJSONParse(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);
        if (enc) {
          enc.psk = true; enc.webrtcSig = true; // flag so receiver knows to decrypt
          body = JSON.stringify(enc);
        } else {
          body = JSON.stringify(signalingMsg); // fallback plaintext if encrypt fails
        }
      } catch {
        body = JSON.stringify(signalingMsg);
      }

      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 = _safeJSONParse(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);
                  // 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 = _safeJSONParse(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 ─────────────────────────────────────────────────────────

  
  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 _rawHistArr = _safeJSONParse(GM_getValue(P2P_GM_HISTORY, '[]'));
      const raw = Array.isArray(_rawHistArr) ? _rawHistArr : [];
      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(_safeJSONParse(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) || _isSSRFBlocked(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 = _safeJSONParse(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 = _safeJSONParse(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 _rawHistArr = _safeJSONParse(GM_getValue(P2P_GM_HISTORY, '[]'));
      const raw = Array.isArray(_rawHistArr) ? _rawHistArr : [];
      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 ─────────────────────────────────────────────────────────

  
  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('');
  }
  // Returns first 12 hex chars of SHA-256 — used as a privacy-preserving reporter fingerprint.
  // BUG FIX: this previously just sliced the raw input string (str.slice(0,8)+'…')
  // without hashing it at all. Since the only caller passes the user's own real,
  // persistent peer ID, every report sent to the relay contained a recognizable
  // prefix of the reporter's actual peer ID — the same ID attached to all their
  // chat messages — which defeated the documented anonymity guarantee entirely.
  // Async because it delegates to crypto.subtle.digest via _sha256() — there is
  // no synchronous Web Crypto API, so callers must await this.
  async function _sha256Short(str) {
    if (typeof str !== 'string' || !str) return 'anon';
    const full = await _sha256(str);
    return full.slice(0, 12);
  }

  // ─── 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']
    );
  }

  
  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 ''; }
  }

  
  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 = _safeJSONParse(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(e => _devWarn('[P2P] _handleEvent failed:', e?.message));
        }
      });
      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 = _safeJSONParse(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 = _safeJSONParse(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 = _safeJSONParse(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 = _safeJSONParse(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, dr: 2, 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 = _safeJSONParse(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();
    },
  };

  
  // 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 = _safeJSONParse(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;
      // ── Per-field type coercion guard ────────────────────────────────────────
      // Enforce expected types on the outer envelope so downstream code is never
      // surprised by a peer sending a number where a string is expected (type confusion).
      if (msg.peer   !== undefined && typeof msg.peer   !== 'string') return;
      if (msg.room   !== undefined && typeof msg.room   !== 'string') return;
      if (msg.ts     !== undefined && typeof msg.ts     !== 'number') return;
      if (msg.nick   !== undefined && typeof msg.nick   !== 'string') { msg.nick = ''; }
      if (msg.text   !== undefined && typeof msg.text   !== 'string') { msg.text = ''; }

      // ── 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 symmetric-only, or v:3 full Double Ratchet), decrypt that too
        // using the per-peer ratchet chain.
        if (msg.ratchet && (msg.v === 2 || msg.v === 3) && 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) {
        // SECURITY: msg.text here is attacker-controlled (any remote peer can
        // send a 'reaction' with arbitrary text). Only render it if it's one
        // of our own known reaction emoji — anything else is dropped, since
        // applyReaction() injects this value via innerHTML.
        if (P2P_REACTION_EMOJIS.includes(msg.text) &&
            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 */, msg.dr === 2).then(async () => {
            // Reply with our ephemeral public key so the initiator can complete their side
            const pubJwk = await P2PCrypto.getEphemeralPublicKeyJwk();
            const reply = {
              v: 1, dr: 2, 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 */, msg.dr === 2).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');
        }).catch(() => {}); // verification failure → no gold border, not a security issue
      }

      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', async 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 ? await _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'); },
              onerror() { topToast('Report failed — try again'); },
            });
          }).catch(e => {
            _devWarn('[JanitorV5] Report encryption failed:', e?.message);
            topToast('Report failed — encryption error');
          });
        }
      });

      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 = `${_esc(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; }
      }
    },
  };

  
  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
      }).catch(e => {
        _devWarn('[JanitorV5] Admin message signing failed:', e?.message);
        topToast('Admin send failed — signing error');
      });
      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' });
      }).catch(() => {}); // permission denied or API unavailable — notification is best-effort
    }
  }

  // ─── 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;
      const hash = await _sha256(pwEl.value);
      if (hash === P2P_ADMIN_HASH) {
        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();
    });
  }

  
  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 _safeJSONParse(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 = _safeJSONParse(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 ─────────────────────────────────

  
  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 ──────────────────────────────────────

}
  
  // Pure transform function — extracted from initAPInterceptor so self-tests
  // can call it directly without mocking fetch.
  // Returns the modified JSON string, or the original if no changes needed.
  // Also returns a metadata object so callers know what was injected.
  function _apTransformBody(bodyStr, extraBans, combinedPrompt, ctxInject, semanticBanEnabled) {
    const parsed = _safeJSONParse(bodyStr);
    if (!parsed || !parsed.userConfig) return { body: bodyStr, injected: false };

    let injected = false;

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

    // bad_words injection + semantic avoidance
    if (extraBans && extraBans.length) {
      const existing = Array.isArray(parsed.userConfig.bad_words)
        ? parsed.userConfig.bad_words : [];
      parsed.userConfig.bad_words = [...new Set([...existing, ...extraBans])];
      injected = true;

      if (semanticBanEnabled !== false) { // default true
        const banList = extraBans.join(', ');
        const semanticInstruction =
          `[CONTENT FILTER — MANDATORY] The following are strictly off-limits ` +
          `for the entire response — not just as exact words but as concepts, ` +
          `synonyms, indirect references, and narrative directions that lead toward them: ` +
          `${banList}. ` +
          `Do not use them, hint at them, or steer the story toward them under any ` +
          `circumstances. If a scene would naturally go there, redirect it elsewhere entirely.`;

        parsed.userConfig.llm_prompt =
          (parsed.userConfig.llm_prompt
            ? parsed.userConfig.llm_prompt + '\n\n'
            : '') + semanticInstruction;
      }
    }

    return { body: JSON.stringify(parsed), injected };
  }

  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) {
              // ── body transform (llm_prompt + bad_words + semantic avoidance) ──
              const { body: transformedBody, injected } = _apTransformBody(
                bodyStr,
                extraBans,
                combined || '',
                ctxInject,
                gget('ap_semantic_ban', true)
              );
              // Track native ban count for the counter UI
              if (extraBans.length) {
                try {
                  const nativeBads = JSON.parse(bodyStr)?.userConfig?.bad_words;
                  gset('ap_native_ban_count', String(Array.isArray(nativeBads) ? nativeBads.length : 0));
                } catch {}
              }

              // ── deleted message scrubbing ──────────────────────────────
              let finalParsed = _safeJSONParse(transformedBody);
              if (finalParsed?.chatMessages && _apDeletedFingerprints.size > 0) {
                finalParsed.chatMessages = finalParsed.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 = finalParsed ? JSON.stringify(finalParsed) : transformedBody;

              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);

}
  
  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()      { try { return _safeJSONParse(gget('ms2_persona_lib', '[]')) || []; } catch { return []; } }
  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()      { try { return _safeJSONParse(gget('ms2_sumhist', '[]')) || []; } catch { return []; } }
  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 _safeJSONParse(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;
  
  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 ────────────────────────────────────────────────

}
  
  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] || '';

}
  
  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_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_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-sec-badge {
      position: absolute;
      top: -4px;
      right: -4px;
      width: 11px;
      height: 11px;
      background: #f59e0b;
      border-radius: 50%;
      border: 2px solid #1a1025;
      pointer-events: none;
      animation: jv5SecBadgePulse 2.2s ease-in-out infinite;
      z-index: 10;
    }
    @keyframes jv5SecBadgePulse {
      0%, 100% { opacity: 1;   transform: scale(1);    }
      50%       { opacity: 0.5; transform: scale(0.75); }
    }
    #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: only use the backdrop as a scroll container when the modal is
       genuinely taller than the viewport (very small screens or landscape).
       On normal-sized screens the internal ms2-settings-body handles scrolling.
       Previously this rule had no @media guard, so the backdrop always became
       the scroll container — swallowing touch-scroll events before they could
       reach ms2-settings-body and making tall tabs (like Security & Legal)
       impossible to scroll on mobile. */
    @media (max-height: 700px) {
      .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 {
      border-radius: 20px;
      background: rgba(255,255,255,0.12); cursor: pointer; transition: background 0.2s;
    }
    /* Hard scope: .ms2-toggle-thumb only gets position:absolute when it's actually
       inside the 36x20 .ms2-toggle-switch box. Without this, a typo'd or missing
       wrapper class leaves inset:0 with no nearby positioned ancestor — the thumb
       then expands to cover the nearest containing block (the settings modal,
       since .ms2-settings-v2 uses contain:content), invisibly intercepting every
       click in the modal and toggling whatever checkbox it happens to wrap.
       (Real bug, fixed in v5.11.1 — Semantic avoidance toggle used class="ms2-toggle"
       instead of "ms2-toggle-switch".) This rule makes the failure mode "unstyled
       checkbox" instead of "modal-wide invisible click trap" if it ever recurs. */
    .ms2-toggle-switch > .ms2-toggle-thumb { position: absolute; inset: 0; }
    .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; }
    /* Legal panel owns its own scroll context.
       max-height is set to exactly fill the settings-body content area so the
       settings-body itself has nothing to scroll (no double scrollbar). */
    .ms2-tab-panel[data-panel="legal"].active {
      overflow-y: auto !important;
      -webkit-overflow-scrolling: touch !important;
      overscroll-behavior: contain !important;
      max-height: calc(88vh - 110px) !important;
      /* Right padding gives the scrollbar the same inset appearance as the
         settings-body scrollbar on other tabs — without it the bar sits flush
         against the modal border. 12px = settings-body's 16px padding minus
         the 4px scrollbar width, matching the visual gap in the About tab. */
      padding-right: 12px !important;
    }
    /* Style the Legal panel scrollbar to match the purple theme */
    .ms2-tab-panel[data-panel="legal"].active::-webkit-scrollbar { width: 4px; }
    .ms2-tab-panel[data-panel="legal"].active::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }
    .ms2-tab-panel[data-panel="legal"].active::-webkit-scrollbar-track { background: transparent; }
    /* Hide settings-body scrollbar when Legal is the active tab — Legal scrolls itself,
       settings-body should not show a second scrollbar next to it. */
    .jv5-legal-active .ms2-settings-body::-webkit-scrollbar { display: none !important; }
    .jv5-legal-active .ms2-settings-body { scrollbar-width: none !important; }
    .ms2-settings-body { overflow-y: auto; padding: 16px; flex: 1; min-height: 0; 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; }

    /* ── Top toast (plain-text, P2P chat notifications) ── */
    .ms2-top-toast {
      position: fixed; top: 54px; left: 50%; transform: translateX(-50%);
      background: rgba(17,17,30,0.96); color: #e2e8f0;
      border: 1px solid rgba(139,92,246,0.4); border-radius: 8px;
      padding: 7px 16px; font-size: 12.5px; font-family: system-ui,sans-serif;
      z-index: 2147483640; pointer-events: none;
      animation: ms2-fade .18s ease; white-space: nowrap;
    }

    /* ── 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;
    }
  `);

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

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

  // ─── HEADER INJECTION SANITIZER ──────────────────────────────────────────
  // Strips CR, LF, and null bytes from any string that will be used as an
  // HTTP header value via GM_xmlhttpRequest. Without this, a crafted API key
  // containing '\r\nX-Evil: pwned' would inject a new header into the request.
  // GM_xmlhttpRequest runs in the privileged extension context and does not
  // apply the same CRLF-rejection that browsers enforce on native fetch/XHR.
  function _sanitizeHeaderVal(v) {
    return String(v ?? '').replace(/[\r\n\0\x0b\x0c]/g, '');
  }

  // ─── SAFE JSON PARSE ─────────────────────────────────────────────────────
  // JSON.parse() treats __proto__ as a literal own-property key, making it a
  // prototype pollution vector even though _safeMergeMsg already blocks the
  // post-parse assignment path. This reviver provides defense-in-depth:
  // any key named __proto__, constructor, or prototype in ANY nested object
  // is silently dropped before the parsed result is returned.
  //
  // Use for all JSON.parse() calls on untrusted data (relay messages, peer
  // WebRTC data-channel frames, network API responses).
  const _PROTO_POISON_KEYS = Object.freeze(new Set(['__proto__', 'constructor', 'prototype']));
  function _safeJSONParse(str) {
    return JSON.parse(str, (key, value) => {
      if (_PROTO_POISON_KEYS.has(key)) return undefined; // drop poisoned key
      return value;
    });
  }

  // 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);
  }
  // ─── GLOBAL ERROR CAPTURE (v5.7.9+) ───────────────────────────────────────
  // When JV5's own code throws (e.g. a selector/DOM-API change breaks
  // injectAndSend), the user previously had to open devtools to find out why.
  // This catches uncaught errors/rejections whose stack trace touches a known
  // JV5 function, shows a toast with the REAL error message (tap to copy the
  // full message+stack), and logs it to a small ring buffer that JV5
  // Diagnostics can read via __jv5.errorLog().
  const _JV5_ERR_LOG_MAX = 8;
  const _jvErrorLog = [];
  // Function names defined in THIS script — if an error's stack trace
  // mentions any of these, it's "ours" and worth surfacing to the user.
  // Add new names here when adding new DOM-interacting features.
  const _JV5_FN_MARKERS = [
    'injectAndSend','_jvSendClick','replaceLatestAIMessage',
    '_p2pSendMessage','_p2pFetchVerified','_p2pAddHistory',
    'pskEncrypt','pskDecrypt','_padMessage','_unpadMessage',
    '_verifyAdminSig','_safeMergeMsg','_runSelfTests',
    'doFABSummarize','doGenerateSummary','doLoadAll',
  ];
  function _jvIsOwnError(err) {
    const stack = (err && err.stack) || '';
    return _JV5_FN_MARKERS.some(name => stack.includes(name));
  }
  function _jvReportError(context, err) {
    const msg = (err && err.message) || String(err);
    const stack = (err && err.stack) || '';
    const entry = { time: new Date().toISOString(), context, message: msg, stack };
    _jvErrorLog.push(entry);
    if (_jvErrorLog.length > _JV5_ERR_LOG_MAX) _jvErrorLog.shift();
    console.error(`[JV5 Error] ${context}:`, err);
    try {
      const t = toastHTML(
        `${SVG_WARNING} JV5 error in <strong>${escHtml(context)}</strong>: ${escHtml(msg)} <span style="opacity:.7;">(tap to copy)</span>`,
        9000
      );
      if (t) {
        t.style.cursor = 'pointer';
        t.addEventListener('click', () => {
          const full = `[JV5 Error] ${context}\n${msg}\n\n${stack}`;
          navigator.clipboard?.writeText(full)
            .then(() => { t.textContent = '✅ Copied — paste this to get help fixing it'; })
            .catch(() => {});
        });
      }
    } catch { /* never let error-reporting itself throw */ }
    return entry;
  }
  window.addEventListener('error', (e) => {
    if (e?.error && _jvIsOwnError(e.error)) _jvReportError('uncaught', e.error);
  });
  window.addEventListener('unhandledrejection', (e) => {
    if (e?.reason instanceof Error && _jvIsOwnError(e.reason)) _jvReportError('unhandled-promise', e.reason);
  });

  // ─── 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);
  
  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(',');

  
  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;
  }

  
  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) ─────────────────────────────────

  
  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]));

}
  
  // ── 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 InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: text }));
    } 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;
      }
      // React 17/18 tracks the native input value via a hidden tracker
      // (`_valueTracker`) to dedupe its synthetic onChange. Resetting it to
      // null forces React to treat this as a genuine change even though we
      // bypassed the setter it normally observes.
      if (input._valueTracker) input._valueTracker.setValue('');
      input.dispatchEvent(new InputEvent('input',  { bubbles: true, cancelable: true, inputType: 'insertText', data: text }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    }
    input.focus();

    setTimeout(() => {
      try {
      const sendSelectors = [
        // v6.4.1 — confirmed via live DOM: real button is
        // `<button aria-label="Send" class="_sendButton_HASH_NN">` (CSS-modules
        // hash suffix varies by build). Match on the stable parts.
        'button[aria-label="Send"]',
        'button[class*="_sendButton_"]',
        'button:has(svg path[d^="M34.9 289.5"])',          // FontAwesome "arrow-up" icon — same icon used inside _sendButton_
        '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" i]:not(:disabled)',
        'button[aria-label="button"]:not(:disabled)',
        '[class*="switcher"]:not(:disabled)',
      ];
      // Pass 1: look for a confirmed Send-button match even if currently
      // disabled, and give React up to ~1.2s to enable it after the input
      // event above (covers slow re-renders / value-tracker propagation).
      const findSendBtn = () => {
        for (const sel of sendSelectors.slice(0, 5)) {
          try {
            const b = Array.from(document.querySelectorAll(sel)).find(
              el => !el.closest('[role="dialog"]') && el.offsetParent !== null
            );
            if (b) return b;
          } catch {}
        }
        return null;
      };
      const candidate = findSendBtn();
      const _jvSendClick = (btn, sel) => {
        try {
          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;
          // NOTE: do NOT set `view: window` here — in Tampermonkey's sandboxed
          // userscript context, `window` isn't recognized as a real Window
          // object by the page's native MouseEvent/PointerEvent constructors,
          // causing "Failed to convert value to 'Window'" and aborting the
          // entire send silently. `view` is optional; omit it.
          const evOpts = { bubbles: true, cancelable: true, 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();
          setTimeout(() => {
            try {
              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,
              });
            } catch (err) { _jvReportError('injectAndSend:_jvSendClick-verify', err); }
          }, 400);
          onSuccess && onSuccess();
        } catch (err) {
          _jvReportError('injectAndSend:_jvSendClick', err);
          _logSendAttempt({ outcome: 'doclick-error', inputSel: _inputSel, btnSel: sel, textLen: text.length, error: err.message });
          onFail && onFail();
        }
      };
      const runGenericLoop = () => {
        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) { _jvSendClick(btn, sel); return true; }
          } catch {  }
        }
        return false;
      };
      if (candidate && !candidate.disabled) {
        _jvSendClick(candidate, '<confirmed-sendbtn>');
      } else if (candidate) {
        // Confirmed Send button found but disabled — React's enabled-check
        // likely hasn't picked up our injected value yet. Poll briefly
        // (covers value-tracker / re-render lag) before giving up on it.
        let tries = 0;
        const poll = () => {
          tries++;
          if (!candidate.disabled) { _jvSendClick(candidate, '<confirmed-sendbtn-delayed>'); return; }
          if (tries >= 8) {
            // Gave up waiting on the confirmed button — try other candidates,
            // then Enter-key fallback.
            if (!runGenericLoop()) {
              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 {
                  _logSendAttempt({ outcome: 'confirmed-btn-stayed-disabled', inputSel: _inputSel, btnSel: '<confirmed-sendbtn>', textLen: text.length });
                  onFail && onFail();
                }
              }, 200);
            }
            return;
          }
          setTimeout(poll, 150);
        };
        poll();
      } else if (!runGenericLoop()) {
        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);
      }
      } catch (err) {
        _jvReportError('injectAndSend', err);
        onFail && onFail();
      }
    }, 150);

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

}
  
  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(() => {
        try {
        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;
        }
        // Same fix as injectAndSend(): reset React's value-tracker so the
        // synthetic onChange actually fires and the Save button's disabled
        // state updates to reflect the new text.
        if (ta._valueTracker) ta._valueTracker.setValue('');
        ta.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: newText }));
        ta.dispatchEvent(new Event('change', { bubbles: true }));
        ta.focus();

        const saveBtnSel = [
          'button[aria-label*="save" i]',
          'button[aria-label*="confirm" i]',
          'button[title*="save" i]',
          '[class*="_save_"]',
          '[class*="saveBtn"]',
        ].join(',');
        // Poll briefly for the Save button to become enabled, same as the
        // Send button polling in injectAndSend() — React's disabled-state
        // re-render can lag the input event by a render cycle or two.
        let tries = 0;
        const trySave = () => {
          tries++;
          const saveBtn = lastAINode.querySelector(saveBtnSel) || document.querySelector(saveBtnSel);
          if (saveBtn && !saveBtn.disabled) {
            saveBtn.click();
            onSuccess && onSuccess();
            return;
          }
          if (tries >= 8) { onFail && onFail(); return; }
          setTimeout(trySave, 150);
        };
        setTimeout(trySave, 250);
        } catch (err) {
          _jvReportError('replaceLatestAIMessage', err);
          onFail && onFail();
        }
      }, 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.

  
  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']         = _sanitizeHeaderVal(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';
      }
    }

    // Sanitize key before header insertion — strips CRLF/null header-injection chars
    const _cleanKey = _sanitizeHeaderVal(key);
    if (resolved === 'x-api-key') {
      headers['x-api-key'] = _cleanKey;
    } else if (resolved === 'raw') {
      headers['Authorization'] = _cleanKey;
    } else {
      // 'bearer' — standard
      headers['Authorization'] = `Bearer ${_cleanKey}`;
    }

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

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

}
  
  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 (_isSSRFBlocked(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 ───────────────────────────────────────────────────
  
  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 ─────────────────────────────────────────────────

}
  
  // Builds a stable CSS selector for a DOM element — mostly for debug logging.
  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();
  }

  
  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;
  
  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;
  
  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}`)
            ).catch(e => {
              _devWarn('[JanitorV5] Auto-summarise failed:', e?.message);
              topToast('Auto-summarise failed — check API key or try manually');
            });
          } 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;
  }
  
  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;
  
  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);
        }).catch(() => {
          // Clipboard API blocked (mobile browser security policy) — fall back
          // to execCommand so the user still gets their text.
          try {
            const ta = document.createElement('textarea');
            ta.value = summary; ta.style.cssText = 'position:fixed;opacity:0';
            document.body.appendChild(ta); ta.select();
            document.execCommand('copy'); ta.remove();
            const b = modal.querySelector('#ms2-fabsum-copy-btn');
            if (b) { b.innerHTML = `${SVG_CHECK} Copied!`; setTimeout(() => { if (b.isConnected) b.innerHTML = `${SVG_COPY} Copy again`; }, 1800); }
          } catch { toastHTML('⚠️ Copy failed — select and copy manually'); }
        });
      });

      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);
          }).catch(() => {
            try {
              const ta = document.createElement('textarea');
              ta.value = resultText; ta.style.cssText = 'position:fixed;opacity:0';
              document.body.appendChild(ta); ta.select();
              document.execCommand('copy'); ta.remove();
              copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
              setTimeout(() => { copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
            } catch { toastHTML('⚠️ Copy failed — select and copy manually'); }
          });
        });

        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) ───────────────────────────────────────────────

  // Renders 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 ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 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 &amp; save key" style="flex-shrink:0;">${SVG_CONFIG}</button>
          </div>
          ${!_pinActive ? `<div style="font-size:10.5px;color:#6b7280;margin-top:3px;line-height:1.45;">
            Paste your key above, then click <strong style="color:#a78bfa;">save to test and save it</strong>
            before setting a PIN. Pressing Enter alone does <em>not</em> save the key.
          </div>` : ''}
          ${_pinActive && !_pinUnlocked ? `<div style="font-size:10.5px;color:#6b7280;margin-top:3px;line-height:1.45;">
            Your API key is encrypted and safe — the dots above are a placeholder.
            Unlock with your PIN to view or change it.
          </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>

          <!-- Export / Import -->
          <div style="display:flex;align-items:center;gap:6px;margin:10px 0 4px;">
            <label class="ms2-field-label" style="margin-bottom:0;">Backup & Restore</label>
            ${_makeInfoBtn('export-import')}
          </div>
          <div style="display:flex;gap:6px;">
            <button id="ms2-export-btn" style="
              flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
              background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.35);
              color:#6ee7b7;font-family:system-ui,sans-serif;">
              ⬇ Export Settings
            </button>
            <button id="ms2-import-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.3);
              color:#818cf8;font-family:system-ui,sans-serif;">
              ⬆ Import Settings
            </button>
          </div>
          <div style="font-size:10.5px;color:#6b7280;margin-top:4px;line-height:1.4;">
            Export saves all your settings and presets as a JSON file.
            ${_pinIsActive() ? 'Your API key is exported <strong style="color:#6ee7b7;">encrypted</strong> — the file is safe to store.' : '<strong style="color:#fbbf24;">⚠ No PIN set</strong> — export will contain your API key in plain text.'}
          </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 600,000 iterations — ~4.3× harder to brute-force than the previous 350k baseline. 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>`;
  }
  // Renders 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>`
  }

  // Renders 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>`;
  }

  // Renders 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>

            <!-- Semantic ban toggle -->
            <div style="display:flex;align-items:flex-start;gap:9px;margin-top:8px;padding:8px 10px;background:rgba(139,92,246,0.06);border:1px solid rgba(139,92,246,0.2);border-radius:7px;">
              <label class="ms2-toggle-switch" style="flex-shrink:0;margin-top:1px;">
                <input type="checkbox" id="ap-semantic-ban-toggle" ${gget('ap_semantic_ban', true) ? 'checked' : ''}>
                <span class="ms2-toggle-thumb"></span>
              </label>
              <div>
                <span style="font-size:12px;color:#e2e8f0;font-weight:500;">Semantic avoidance</span>
                <div class="ms2-tip" style="margin-top:2px;">
                  Also injects a prompt-level instruction telling the model to avoid the concept entirely — not just the exact token.
                  Fixes the repetition loop where the bot says synonyms until it circles back to the banned word.
                  Recommended: <strong style="color:#a78bfa;">on</strong>.
                </div>
              </div>
            </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)));

    // Export / Import
    settingsPanel.querySelector('#ms2-export-btn')?.addEventListener('click', () => _exportSettings());
    settingsPanel.querySelector('#ms2-import-btn')?.addEventListener('click', () => _importSettings());

    // 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(); });
    });
  }

  // Shows the one-time recovery code display modal after PIN set/change.
  function _showRecoveryCodeModal(code) {
    const ov = document.createElement('div');
    ov.className = 'jv5-pin-modal';
    ov.innerHTML = `
      <div class="jv5-pin-box" style="max-width:360px;">
        <h3 style="color:#fbbf24;">🔑 Save Your Recovery Code</h3>
        <p style="color:#d1d5db;font-size:12px;line-height:1.6;margin-bottom:12px;">
          This code lets you reset your PIN if you forget it. <strong style="color:#fca5a5;">You will never see it again</strong>
          — write it down or save it somewhere secure (password manager, paper, etc.)
        </p>
        <div id="jv5-rc-display" style="
          background:#0d0d1a;border:1px solid rgba(139,92,246,0.5);border-radius:8px;
          padding:12px 14px;font-family:monospace;font-size:16px;font-weight:700;
          letter-spacing:2px;color:#c4b5fd;text-align:center;margin-bottom:10px;
          word-break:break-all;">${code}</div>
        <div style="display:flex;gap:8px;margin-bottom:12px;">
          <button id="jv5-rc-copy" style="flex:1;padding:7px;font-size:11.5px;font-weight:600;
            background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.4);
            border-radius:7px;color:#c4b5fd;cursor:pointer;font-family:system-ui,sans-serif;">
            📋 Copy Code
          </button>
        </div>
        <p style="font-size:10.5px;color:#6b7280;line-height:1.5;margin-bottom:12px;">
          The recovery code is NOT stored anywhere in the script — only a hash is kept to verify it.
          If you lose this code AND forget your PIN, the encrypted key cannot be recovered.
        </p>
        <button id="jv5-rc-done" style="width:100%;padding:9px;font-size:12px;font-weight:600;
          background:linear-gradient(135deg,#7c3aed,#6d28d9);border:none;border-radius:8px;
          color:#fff;cursor:pointer;font-family:system-ui,sans-serif;">
          I've saved it — Done
        </button>
      </div>`;
    document.body.appendChild(ov);
    ov.querySelector('#jv5-rc-copy').addEventListener('click', async () => {
      try {
        await navigator.clipboard.writeText(code);
        ov.querySelector('#jv5-rc-copy').textContent = '✓ Copied!';
      } catch {
        // Fallback for browsers that block clipboard
        const inp = document.createElement('input');
        inp.value = code;
        document.body.appendChild(inp);
        inp.select();
        document.execCommand('copy');
        inp.remove();
        ov.querySelector('#jv5-rc-copy').textContent = '✓ Copied!';
      }
    });
    ov.querySelector('#jv5-rc-done').addEventListener('click', () => {
      ov.remove();
      toastHTML(`${SVG_SHIELD} PIN set — recovery code saved`);
    });
  }

  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. A recovery code will be generated so you can reset if you forget.', confirm: 'Encrypt & Save', fields: ['new','confirm'], showGenerator: true },
      unlock: { title: `${SVG_UNLOCK} Unlock API Key`, sub: 'Enter your PIN to decrypt the API key for this session.', confirm: 'Unlock', fields: ['pin'], showRecovery: true },
      change: { title: '✎ Change PIN', sub: 'Enter your current PIN, then set a new one. A fresh recovery code will be generated.', confirm: 'Change PIN', fields: ['current','new','confirm'], showGenerator: true },
      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 8 chars, must include a letter)" 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;">` : ''}
        ${cfg.showGenerator ? `
        <div style="display:flex;gap:6px;margin:-4px 0 12px;">
          <button id="jv5-gen-chars" style="flex:1;padding:5px 6px;font-size:10.5px;font-weight:600;
            background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.35);
            border-radius:6px;color:#c4b5fd;cursor:pointer;font-family:system-ui,sans-serif;">
            ⚡ Generate PIN
          </button>
          <button id="jv5-gen-phrase" style="flex:1;padding:5px 6px;font-size:10.5px;font-weight:600;
            background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.3);
            border-radius:6px;color:#818cf8;cursor:pointer;font-family:system-ui,sans-serif;">
            🔤 Generate Passphrase
          </button>
        </div>` : ''}
        ${cfg.showRecovery ? `
        <div style="text-align:right;margin:-6px 0 10px;">
          <button id="jv5-forgot-pin" style="background:none;border:none;font-size:11px;
            color:#8b5cf6;cursor:pointer;text-decoration:underline;padding:0;
            font-family:system-ui,sans-serif;">Forgot your PIN?</button>
        </div>` : ''}
        <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(); });

    // ── PIN Generator buttons ──────────────────────────────────────────────
    const _wireGen = (btnId, mode) => {
      const btn = overlay.querySelector(`#${btnId}`);
      if (!btn) return;
      btn.addEventListener('click', () => {
        const pin = _generateStrongPin(mode);
        const newInp  = overlay.querySelector('#jv5-pin-new');
        const confInp = overlay.querySelector('#jv5-pin-confirm');
        if (newInp)  { newInp.value  = pin; newInp.type  = 'text'; }
        if (confInp) { confInp.value = pin; confInp.type = 'text'; }
        // Show the PIN for 4 seconds then re-mask it
        errEl.style.color = '#6ee7b7';
        errEl.textContent = `Generated — copy it now, then keep it somewhere safe!`;
        setTimeout(() => {
          if (newInp)  newInp.type  = 'password';
          if (confInp) confInp.type = 'password';
          if (errEl.textContent.startsWith('Generated')) {
            errEl.style.color = '';
            errEl.textContent = '';
          }
        }, 4000);
      });
    };
    _wireGen('jv5-gen-chars',  'chars');
    _wireGen('jv5-gen-phrase', 'passphrase');

    // ── Forgot PIN → recovery code flow ───────────────────────────────────
    overlay.querySelector('#jv5-forgot-pin')?.addEventListener('click', async () => {
      const hasBackup = !!gget(_KEY_RECOVERY_HASH, '');
      if (!hasBackup) {
        errEl.style.color = '#f87171';
        errEl.textContent = 'No recovery code was generated for this PIN — it was set before v5.11. You must remove the script, re-install, and re-enter your key.';
        return;
      }
      const entered = await _promptModal({
        title: '🔑 Enter Recovery Code',
        placeholder: 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX',
        type: 'text',
        confirm: 'Recover',
      });
      if (!entered) return;
      // Validate format before even attempting decryption — a well-formed code
      // is 6 groups of 4 uppercase alphanumeric chars separated by dashes.
      const _rcNorm = entered.replace(/[-\s]/g, '').toUpperCase();
      if (_rcNorm.length !== 24 || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{24}$/.test(_rcNorm)) {
        errEl.style.color = '#f87171';
        errEl.textContent = 'Format looks wrong — should be 6 groups of 4 characters (e.g. ABCD-EFGH-JKLM-NPQR-STUV-WXYZ). No 0, O, I or 1.';
        return;
      }
      const recovered = await _recoverWithCode(entered);
      if (recovered === null) {
        errEl.style.color = '#f87171';
        errEl.textContent = 'Recovery code is wrong. Check for typos (no 0, O, I, 1 — use the letters/numbers shown).';
        return;
      }
      // Code verified — let them set a new PIN
      overlay.remove();
      // Small delay so the old overlay is fully gone before the new one opens
      setTimeout(() => {
        _showPinModal('set', async (resultMode) => {
          // After new PIN is set, the API key is already encrypted.
          // Sync the session key so the UI reflects it.
          CFG.apiKey = recovered;
          toastHTML(`${SVG_SHIELD} PIN reset via recovery code — key re-encrypted.`);
        });
        // Pre-fill what we recovered so _pinEncryptKey has it
        // (it reads CFG.apiKey which reads _sessionApiKey or plain store)
        gset(_KEY_PLAIN_GM, recovered);
      }, 100);
    });

    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 < 8)       { errEl.textContent = 'PIN must be at least 8 characters.'; return; }
          if (/^\d+$/.test(newPin))    { errEl.textContent = 'PIN must contain at least one letter — all-numeric PINs are too easy to brute-force.'; 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; }
          // Generate recovery code and store encrypted backup
          const _rc = _generateRecoveryCode();
          try {
            await _storeRecoveryBackup(currentKey, _rc);
          } catch (storeErr) {
            _devWarn('[JanitorV5] Recovery backup failed:', storeErr);
            overlay.remove();
            toastHTML(`${SVG_SHIELD} PIN set, but recovery code could not be saved (${storeErr.message}). Consider exporting a backup instead.`, 8000);
            onSuccess && onSuccess('set');
            return;
          }
          overlay.remove();
          _showRecoveryCodeModal(_rc);
          onSuccess && onSuccess('set');

        } else if (mode === 'unlock') {
          const pin = overlay.querySelector('#jv5-pin-input')?.value || '';
          if (!pin)                    { errEl.textContent = 'Enter your PIN.'; return; }
          // Brute-force lockout check.
          // _pinCheckLockout() returns a number (seconds remaining) when locked,
          // or PIN_LOCKOUT_NEVER_FAILED / PIN_LOCKOUT_EXPIRED when the attempt is allowed.
          const _waitSec = _pinCheckLockout();
          if (typeof _waitSec === 'number') {
            const _mins = Math.ceil(_waitSec / 60);
            errEl.textContent = `Too many failed attempts — locked for ${_waitSec >= 60 ? _mins + ' minute' + (_mins !== 1 ? 's' : '') : _waitSec + ' second' + (_waitSec !== 1 ? 's' : '')}. Reload the page to reset.`;
            return;
          }
          const decrypted = await _pinDecryptKey(pin);
          if (decrypted === null) {
            _pinRecordFailure();
            const _attemptsLeft = Math.max(0, 5 - _pinFailCount);
            errEl.textContent = `Wrong PIN${_attemptsLeft > 0 ? ' — ' + _attemptsLeft + ' attempt' + (_attemptsLeft !== 1 ? 's' : '') + ' remaining before lockout' : ' — locked out (too many attempts)'}. Reload to reset.`;
            return;
          }
          _pinFailCount = 0; _pinLockUntil = 0; // reset on success
          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 < 8)       { errEl.textContent = 'New PIN must be at least 8 characters.'; return; }
          if (/^\d+$/.test(newPin))    { errEl.textContent = 'New PIN must contain at least one letter.'; 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;
          // Regenerate recovery code for the new PIN
          const _rc2 = _generateRecoveryCode();
          try {
            await _storeRecoveryBackup(decrypted, _rc2);
          } catch (storeErr) {
            _devWarn('[JanitorV5] Recovery backup (change) failed:', storeErr);
            overlay.remove();
            toastHTML(`${SVG_SHIELD} PIN changed, but new recovery code could not be saved. Export a backup now.`, 8000);
            onSuccess && onSuccess('change');
            return;
          }
          overlay.remove();
          _showRecoveryCodeModal(_rc2);
          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 {}
          _clearRecoveryBackup();
          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', 'legal'];
    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`, legal: `${SVG_SHIELD} Security & Legal` };

    // 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">

            <!-- What's New card -->
            <div id="ms2-whats-new-btn" style="
              background:rgba(99,102,241,0.09);border:1px solid rgba(99,102,241,0.35);
              border-radius:9px;padding:10px 13px;margin-bottom:14px;cursor:pointer;
              display:flex;align-items:center;gap:10px;
              transition:background .15s;-webkit-tap-highlight-color:transparent;
            " role="button" tabindex="0" aria-label="Open changelog">
              <div style="width:30px;height:30px;background:rgba(99,102,241,0.2);border-radius:7px;
                display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0;">📋</div>
              <div style="flex:1;min-width:0;">
                <div style="font-size:12px;font-weight:700;color:#818cf8;">
                  What's New in ${_CHANGELOG[0].version}
                </div>
                <div style="font-size:10.5px;color:#6b7280;margin-top:1px;">
                  ${_CHANGELOG[0].sections.reduce((t,s) => t + s.items.length, 0)} changes — tap to see full changelog
                </div>
              </div>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2"
                stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
            </div>

            <div class="ms2-about-title">JanitorV5 — Smart RP Toolkit</div>
            <div class="ms2-about-version">v5.11.1 — Full Double Ratchet (DH re-keying + post-compromise security, with skipped-key handling for out-of-order delivery and legacy-peer fallback) · SSRF guard now catches decimal/hex/octal/shorthand IPv4 bypasses · fresh salt on every PIN re-encryption · gated crypto-diagnostic hooks behind dev mode · P2P reaction-emoji XSS fix · network-patch integrity check · automatic in-app runtime error reporting · v5.11.1 — 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 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>

        <!-- SECURITY & LEGAL -->
        <div class="ms2-tab-panel ${tab0 === 'legal' ? 'active' : ''}" data-panel="legal">
          <div class="ms2-about-box" style="color:#d1d5db;">

            <!-- ── TRUST MODEL ────────────────────────────────────────── -->
            <div style="background:rgba(251,191,36,0.07);border:1px solid rgba(251,191,36,0.25);border-radius:8px;padding:11px 13px;margin-bottom:14px;">
              <div style="font-size:11px;font-weight:700;color:#fbbf24;letter-spacing:.6px;margin-bottom:7px;">⚠️ BEFORE YOU TRUST THIS SCRIPT</div>
              <div style="line-height:1.75;font-size:12px;">
                I want to be upfront with you: JanitorV5 runs with <strong style="color:#fde68a;">elevated browser permissions</strong> on every JanitorAI page.
                That means it can read and modify page content, intercept network requests, and access your stored API key.
                Every auto-update through your userscript manager installs the new version with those same permissions — <strong style="color:#fde68a;">automatically, with no review step.</strong>
              </div>
              <div style="margin-top:8px;line-height:1.75;font-size:12px;">
                That's not unique to this script — it's how all userscripts work. But it means you're trusting <strong>me (eivls)</strong> on every single update, indefinitely.
                I take that seriously, which is why I'm telling you directly instead of burying it.
                If you're not comfortable with that model, you shouldn't install or keep this script — and that's a completely reasonable call.
              </div>
            </div>

            <!-- ── WHAT I CAN SEE ────────────────────────────────────── -->
            <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin:12px 0 6px;">${SVG_SHIELD} WHAT THE SCRIPT CAN ACCESS</div>
            <div class="ms2-about-row">• Your <strong>API key</strong> — stored in GM storage (isolated from the page), optionally encrypted under your PIN using AES-256-GCM. I never transmit it anywhere except your configured AI provider.</div>
            <div class="ms2-about-row">• <strong>Visible message text</strong> on the current page — required for Reply, Shorten, Summarise, and Context features. This stays on your device except when you explicitly send it to your AI provider.</div>
            <div class="ms2-about-row">• <strong>Network requests</strong> — fetch and XHR are patched to run the circuit breaker. Your full chat payloads are <em>not</em> logged to storage. That was removed in v5.7 and won't come back.</div>
            <div class="ms2-about-row">• <strong>Your settings, presets, and personas</strong> in GM storage. Note: other userscripts on the same site can read GM storage too if they have the <code style="color:#a78bfa;">GM_getValue</code> grant.</div>
            <div class="ms2-about-row">• <strong>Community Chat messages</strong> you send are relayed through ntfy.sh. Treat it like a public space — don't put anything personal in there.</div>

            <!-- ── REMOTE SELECTOR URL ───────────────────────────────── -->
            <div style="background:rgba(239,68,68,0.07);border:1px solid rgba(239,68,68,0.25);border-radius:8px;padding:11px 13px;margin:14px 0;">
              <div style="font-size:11px;font-weight:700;color:#fca5a5;letter-spacing:.6px;margin-bottom:7px;">🔴 REMOTE SELECTOR URL — ADVANCED / HIGH RISK</div>
              <div style="line-height:1.75;font-size:12px;">
                There's a hidden feature (<code style="color:#fca5a5;">jv5_selector_remote_url</code> in GM storage) that lets an external server push CSS selector overrides to the script.
                I built it for my own debugging — it is <strong style="color:#fca5a5;">not a feature you should be setting based on someone else's instructions.</strong>
              </div>
              <div style="margin-top:7px;line-height:1.75;font-size:12px;">
                If someone in a Discord, Reddit thread, or anywhere else tells you to set that key to their URL — don't.
                A compromised URL can redirect the script's DOM reads silently. The script will show a console warning and a banner toast any time this setting is active, so you know if it's on.
                To turn it off: open your userscript manager's storage editor and delete <code style="color:#fca5a5;">jv5_selector_remote_url</code>.
              </div>
            </div>

            <!-- ── PIN & ENCRYPTION ──────────────────────────────────── -->
            <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin:12px 0 6px;">${SVG_LOCK} YOUR PIN & ENCRYPTION</div>
            <div class="ms2-about-row">If you set a PIN, your API key is encrypted with AES-256-GCM using a key derived from your PIN via PBKDF2 (310,000 rounds, SHA-256) with a random salt. The decrypted key only lives in memory for your current session — it's gone on reload or idle timeout.</div>
            <div class="ms2-about-row"><strong>I do not store your PIN anywhere.</strong> If you forget it, the encrypted key is unrecoverable. There is no reset, no recovery email, nothing. Write it down somewhere safe.</div>
            <div class="ms2-about-row">After 5 wrong attempts the input locks out with exponential backoff (30 s → 1 min → 2 min, up to 15 min). Every PIN change generates a fresh salt, so old encrypted blobs are permanently dead even if someone had a copy.</div>

            <!-- ── NETWORK & SSRF ────────────────────────────────────── -->
            <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin:12px 0 6px;">🌐 OUTBOUND CONNECTIONS</div>
            <div class="ms2-about-row">The script only contacts: your AI provider, ntfy.sh for Community Chat, and your remote selector URL if you've configured one. That's it.</div>
            <div class="ms2-about-row">Any user-supplied URL (custom endpoint, relay, selector URL) is checked against an SSRF block-list that rejects private IPs, loopback addresses, and link-local ranges — including decimal, hex, octal, and shorthand IPv4 tricks that bypass plain string checks. This stops the script from being weaponised to probe your local network.</div>
            <div class="ms2-about-row">A circuit breaker kicks in after repeated failures so you don't silently burn through your API quota if something goes wrong.</div>

            <!-- ── COMMUNITY CHAT E2E ─────────────────────────────────── -->
            <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin:12px 0 6px;">${SVG_SHIELD} COMMUNITY CHAT ENCRYPTION</div>
            <div class="ms2-about-row">P2P sessions in Community Chat use a Double Ratchet (DH re-keying, forward secrecy, post-compromise recovery). Messages are encrypted on your device and decrypted on theirs — ntfy.sh only sees ciphertext.</div>
            <div class="ms2-about-row">That said: ntfy.sh can still see metadata (who's active, message timing). The global room is shared with everyone running JanitorV5. Keep it casual — it's not a private channel.</div>

            <!-- ── LICENSE ───────────────────────────────────────────── -->
            <div style="background:rgba(139,92,246,0.07);border:1px solid rgba(139,92,246,0.25);border-radius:8px;padding:11px 13px;margin:14px 0 6px;">
              <div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.6px;margin-bottom:7px;">📄 LICENSE — ALL RIGHTS RESERVED © 2025 eivls</div>
              <div style="line-height:1.75;font-size:12px;">
                You can install and use this script for personal, non-commercial use. That's the intended use case and you're welcome to it.
              </div>
              <div style="margin-top:8px;line-height:1.75;font-size:12px;">
                What you <strong style="color:#fca5a5;">cannot</strong> do: redistribute it (modified or not), publish it under a different name, sell it, sublicense it, remove my name from it, or re-host it on another update channel. <strong style="color:#fca5a5;">Forking and publishing publicly is a copyright violation</strong> — even if you change the name or modify the code. "All Rights Reserved" means exactly that.
              </div>
              <div style="margin-top:8px;line-height:1.75;font-size:12px;color:#9ca3af;">
                Keeping a private local copy on your own machine (not published, not shared) is fine — but you're on your own for security updates if you do that.
              </div>
            </div>
            <div class="ms2-about-row" style="color:#6b7280;font-size:11px;margin-top:8px;">This script is provided as-is. I'm not liable for API charges, data loss, or account actions from its use. You're an adult making an informed choice — this tab exists so that choice is actually informed.</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');
        // Reset scroll to top on every tab switch so the new panel always
        // starts from the beginning regardless of where the previous tab was.
        const body = panel.querySelector('.ms2-settings-body');
        if (body) body.scrollTop = 0;
        // Toggle class so CSS can hide settings-body's scrollbar when Legal
        // is active (Legal owns its own scroll context and purple scrollbar).
        panel.classList.toggle('jv5-legal-active', tab.dataset.tab === 'legal');
      });
    });
    // Set the class on initial open if Legal is the starting tab.
    if (tab0 === 'legal') panel.classList.add('jv5-legal-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 (_isSSRFBlocked(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);
        }
      });
    });

    // What's New card in About tab — opens changelog without cooldown/agreements
    const _wnBtn = panel.querySelector('#ms2-whats-new-btn');
    if (_wnBtn) {
      _wnBtn.addEventListener('click', () => _showChangelogModal(false));
      _wnBtn.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') _showChangelogModal(false); });
      _wnBtn.addEventListener('mouseover', () => { _wnBtn.style.background = 'rgba(99,102,241,0.16)'; });
      _wnBtn.addEventListener('mouseout',  () => { _wnBtn.style.background = 'rgba(99,102,241,0.09)'; });
    }

    // 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 = _safeJSONParse(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.defaultTone;
      CFG.defaultInstruct = (panel.querySelector('#ms2-s-default-instruct')?.value ?? '').trim() || CFG.defaultInstruct;
      CFG.autoNotify      = panel.querySelector('#ms2-s-autonotify')?.checked     ?? CFG.autoNotify;
      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 = _safeJSONParse(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);


    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(); }
      });

      // Semantic avoidance toggle
      const semanticToggle = panel.querySelector('#ap-semantic-ban-toggle');
      if (semanticToggle) {
        semanticToggle.addEventListener('change', () => {
          gset('ap_semantic_ban', semanticToggle.checked);
          toastHTML(semanticToggle.checked
            ? '🔒 Semantic avoidance on — concept-level instruction added to prompt'
            : '⚠️ Semantic avoidance off — token blocking only (repetition loops may return)');
        });
      }

      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 = _safeJSONParse(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;

  
  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>`;
    // Security badge: pulsing amber dot when no PIN is protecting the API key.
    // Appears on the FAB corner as a persistent nudge to set a PIN.
    if (!_pinIsActive()) {
      const _secBadge = document.createElement('span');
      _secBadge.id    = 'ms2-fab-sec-badge';
      _secBadge.title = 'No PIN set — API key stored unencrypted in Tampermonkey storage.\nOpen Settings → API Key → Set PIN to protect it.';
      fab.appendChild(_secBadge);
    }
    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;   
  
  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 ──────────────────────────────────────────────────────────────────

  
  // ─── 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 ────────────────────────────────────────
    // Skip this test when PIN is active but not yet unlocked — P2P_PSK is null
    // by design in that state (the GM half is PIN-encrypted). This is correct
    // behaviour, not a bug. The test would always FAIL with a misleading error
    // message in that situation, masking real failures in other tests.
    try {
      if (!P2P_PSK) {
        // P2P_PSK is null: PSK is PIN-locked. Mark as skipped, not failed.
        console.info('[JV5 SelfTest] ⏭ pskEncrypt→pskDecrypt skipped — PSK is PIN-locked (expected when PIN active)');
        _stAssert('pskEncrypt→pskDecrypt (skipped: PSK PIN-locked — unlock to run)', true, 'n/a');
      } else {
        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);
    }

    // ── 11. _safeJSONParse prototype-poison key stripping ──────────────────
    try {
      const poisoned  = '{"__proto__":{"jvPwned":true},"text":"ok","constructor":{"x":1}}';
      const parsed    = _safeJSONParse(poisoned);
      const noProto   = parsed.__proto__ === undefined || parsed.__proto__ === Object.prototype;
      const noCtorKey = !Object.prototype.hasOwnProperty.call(parsed, 'constructor');
      const textOk    = parsed.text === 'ok';
      _stAssert('_safeJSONParse strips __proto__ + constructor keys', noProto && noCtorKey && textOk,
        `__proto__=${JSON.stringify(parsed.__proto__)} hasOwn(constructor)=${!noCtorKey} text=${parsed.text}`);
    } catch(e) {
      _stAssert('_safeJSONParse strips __proto__ + constructor keys', false, e.message);
    }

    // ── 12. _sanitizeHeaderVal CRLF stripping ──────────────────────────────
    try {
      const dirty  = 'sk-valid\r\nX-Evil: injected\r\nX-More: pwned';
      const clean  = _sanitizeHeaderVal(dirty);
      const noCRLF = !clean.includes('\r') && !clean.includes('\n');
      _stAssert('_sanitizeHeaderVal strips CRLF injection', noCRLF,
        noCRLF ? '' : `result still contains control chars: ${JSON.stringify(clean)}`);
    } catch(e) {
      _stAssert('_sanitizeHeaderVal strips CRLF injection', false, e.message);
    }

    // ── PIN Generator & Recovery Code ─────────────────────────────────────
    try {
      // chars mode: correct length, contains a letter, charset is 64 chars
      const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz23456789!@#$%^&*';
      _stAssert('PIN charset is exactly 64 chars', charset.length === 64,
        `got ${charset.length}`);
      _stAssert('PIN charset has zero modulo bias (256 % 64)', 256 % charset.length === 0,
        `256 % ${charset.length} = ${256 % charset.length}`);
      const charPin = _generateStrongPin('chars');
      _stAssert('chars PIN is 14 chars', charPin.length === 14,
        `got length ${charPin.length}: "${charPin}"`);
      _stAssert('chars PIN contains a letter', /[a-zA-Z]/.test(charPin),
        `no letter found in: "${charPin}"`);
      _stAssert('chars PIN only uses charset chars', [...charPin].every(c => charset.includes(c)),
        `unexpected char in: "${charPin}"`);
      // passphrase mode: 4 words, joined by dashes
      const phrase = _generateStrongPin('passphrase');
      const parts  = phrase.split('-');
      _stAssert('passphrase has 4 words', parts.length === 4,
        `got ${parts.length} parts: "${phrase}"`);
      _stAssert('passphrase words are non-empty', parts.every(w => w.length > 0),
        `empty word in: "${phrase}"`);
      // Two calls should not produce the same PIN (astronomically unlikely if working)
      const pin2 = _generateStrongPin('chars');
      _stAssert('PIN generator produces unique values', charPin !== pin2,
        `collision: both produced "${charPin}"`);
    } catch(e) {
      _stAssert('PIN generator', false, e.message);
    }

    try {
      // Recovery code: format XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 groups of 4)
      const rc = _generateRecoveryCode();
      const rcParts = rc.split('-');
      _stAssert('recovery code has 6 groups', rcParts.length === 6,
        `got ${rcParts.length}: "${rc}"`);
      _stAssert('recovery code groups are 4 chars each', rcParts.every(p => p.length === 4),
        `bad group lengths: ${rcParts.map(p=>p.length).join(',')}`);
      _stAssert('recovery code uses only valid chars', /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789-]+$/.test(rc),
        `invalid chars in: "${rc}"`);
      // Hash is deterministic and hex
      const h1 = await _hashRecoveryCode(rc);
      const h2 = await _hashRecoveryCode(rc);
      _stAssert('recovery hash is 64-char hex', /^[0-9a-f]{64}$/.test(h1),
        `got: "${h1}"`);
      _stAssert('recovery hash is deterministic', h1 === h2,
        `h1=${h1} h2=${h2}`);
      // Hash ignores dashes and case
      const rcNoDash = rc.replace(/-/g, '');
      const hNoDash  = await _hashRecoveryCode(rcNoDash);
      _stAssert('recovery hash normalises dashes+case', h1 === hNoDash,
        `with-dash=${h1} no-dash=${hNoDash}`);
      // Wrong code returns null from _recoverWithCode
      // (skip full encrypt test here — covered in PIN encrypt round-trip)
      const wrongResult = await _recoverWithCode('AAAA-AAAA-AAAA-AAAA-AAAA-AAAA');
      _stAssert('wrong recovery code returns null', wrongResult === null,
        `expected null, got: ${wrongResult}`);
    } catch(e) {
      _stAssert('recovery code system', false, e.message);
    }

    try {
      // Export key list: no phantom keys, all keys are strings
      _stAssert('_EXPORT_KEYS is non-empty array', Array.isArray(_EXPORT_KEYS) && _EXPORT_KEYS.length > 10,
        `length=${_EXPORT_KEYS.length}`);
      _stAssert('_EXPORT_KEYS entries are all strings', _EXPORT_KEYS.every(k => typeof k === 'string'),
        `non-string entry found`);
      _stAssert('_EXPORT_KEYS has no duplicates',
        new Set(_EXPORT_KEYS).size === _EXPORT_KEYS.length,
        `${_EXPORT_KEYS.length - new Set(_EXPORT_KEYS).size} duplicate(s)`);
      // Core keys must be present
      for (const required of [
        'ms2_apiKey', 'ms2_endpoint', 'ms2_persona_lib', 'ap_presets', 'jv4_p2p_admin_hash',
        // PIN recovery backup — regression guard: these were missing from
        // _EXPORT_KEYS for several versions, silently breaking recovery-code
        // restore on import. See export key list fix in _EXPORT_KEYS above.
        'jv5_pin_recovery_hash', 'jv5_pin_recovery_enc', 'jv5_pin_recovery_salt',
      ]) {
        _stAssert(`_EXPORT_KEYS includes ${required}`, _EXPORT_KEYS.includes(required), '');
      }
    } catch(e) {
      _stAssert('export key list', false, e.message);
    }

    // ── 13. AP Interceptor — body transform ───────────────────────────────
    // Tests _apTransformBody() directly — the pure function the interceptor
    // delegates to. No network calls needed.
    try {
      const _baseBody = () => JSON.stringify({
        userConfig: { llm_prompt: 'original system prompt', bad_words: ['existing-ban'] },
        chatMessages: [{ role: 'user', content: 'hello' }],
      });

      // a) bad_words merged, no duplicates
      const r1 = JSON.parse(_apTransformBody(_baseBody(), ['new-word', 'existing-ban'], '', '', false).body);
      const bw1 = r1.userConfig.bad_words;
      _stAssert('AP: bad_words merges new bans', bw1.includes('new-word'),
        `bad_words: ${JSON.stringify(bw1)}`);
      _stAssert('AP: bad_words deduplicates existing-ban', bw1.filter(w => w === 'existing-ban').length === 1,
        `duplicate found: ${JSON.stringify(bw1)}`);
      _stAssert('AP: bad_words preserves original bans', bw1.includes('existing-ban'),
        `original ban missing: ${JSON.stringify(bw1)}`);

      // b) semantic avoidance appended to llm_prompt when enabled
      const r2 = JSON.parse(_apTransformBody(_baseBody(), ['violence', 'blood'], '', '', true).body);
      const prompt2 = r2.userConfig.llm_prompt;
      _stAssert('AP: semantic instruction appended to llm_prompt', prompt2.includes('[CONTENT FILTER'),
        `prompt: ${prompt2.slice(0, 80)}`);
      _stAssert('AP: semantic instruction contains banned word', prompt2.includes('violence'),
        `banned word missing from prompt`);
      _stAssert('AP: semantic instruction covers synonyms', prompt2.includes('synonyms'),
        `synonyms mention missing from prompt`);
      _stAssert('AP: original llm_prompt preserved before instruction',
        prompt2.startsWith('original system prompt'),
        `original prompt was overwritten: ${prompt2.slice(0, 60)}`);

      // c) semantic avoidance disabled — no injection
      const r3 = JSON.parse(_apTransformBody(_baseBody(), ['test-ban'], '', '', false).body);
      _stAssert('AP: semantic disabled → no [CONTENT FILTER] in prompt',
        !r3.userConfig.llm_prompt.includes('[CONTENT FILTER'),
        `filter unexpectedly present`);

      // d) no bans → bad_words unchanged
      const r4 = JSON.parse(_apTransformBody(_baseBody(), [], '', '', true).body);
      _stAssert('AP: no bans → bad_words untouched',
        JSON.stringify(r4.userConfig.bad_words) === JSON.stringify(['existing-ban']),
        `unexpected: ${JSON.stringify(r4.userConfig.bad_words)}`);

      // e) llm_prompt injection (AP system prompt)
      const r5 = JSON.parse(_apTransformBody(_baseBody(), [], 'AP SYSTEM PROMPT', '', true).body);
      _stAssert('AP: combined prompt replaces llm_prompt',
        r5.userConfig.llm_prompt === 'AP SYSTEM PROMPT',
        `got: ${r5.userConfig.llm_prompt}`);

      // f) context inject appended after combined prompt
      const r6 = JSON.parse(_apTransformBody(_baseBody(), [], 'BASE PROMPT', 'SCENE CONTEXT', true).body);
      _stAssert('AP: context inject appended after combined prompt',
        r6.userConfig.llm_prompt.includes('== SCENE CONTEXT') &&
        r6.userConfig.llm_prompt.startsWith('BASE PROMPT'),
        `got: ${r6.userConfig.llm_prompt}`);

      // g) empty bad_words array in source → still merges correctly
      const emptyBans = JSON.stringify({ userConfig: { llm_prompt: '', bad_words: [] } });
      const r7 = JSON.parse(_apTransformBody(emptyBans, ['word-a', 'word-b'], '', '', false).body);
      _stAssert('AP: merges into empty bad_words array',
        r7.userConfig.bad_words.length === 2 &&
        r7.userConfig.bad_words.includes('word-a') &&
        r7.userConfig.bad_words.includes('word-b'),
        `got: ${JSON.stringify(r7.userConfig.bad_words)}`);

      // h) missing bad_words key → created from scratch
      const noBans = JSON.stringify({ userConfig: { llm_prompt: '' } });
      const r8 = JSON.parse(_apTransformBody(noBans, ['word-c'], '', '', false).body);
      _stAssert('AP: creates bad_words key when missing',
        Array.isArray(r8.userConfig.bad_words) && r8.userConfig.bad_words.includes('word-c'),
        `got: ${JSON.stringify(r8.userConfig.bad_words)}`);

      // i) no userConfig → body returned unchanged (no crash)
      const noConfig = JSON.stringify({ chatMessages: [] });
      const r9 = _apTransformBody(noConfig, ['word'], '', '', true);
      _stAssert('AP: no userConfig → body returned unchanged without crash',
        !r9.injected && _safeJSONParse(r9.body) !== null,
        `body: ${r9.body.slice(0, 60)}`);

      // j) injected flag is correct
      _stAssert('AP: injected=true when bans present',
        _apTransformBody(_baseBody(), ['x'], '', '', false).injected === true, '');
      _stAssert('AP: injected=false when nothing to inject',
        _apTransformBody(_baseBody(), [], '', '', true).injected === false, '');

    } catch (e) {
      _stAssert('AP interceptor transform', 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.
    // The bridge is frozen, so we write to the closure variable that the getter reads.
    try {
      _jv5SelfTestResults = {
        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.11.1' });

    // ── 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; } })();
    let _jv5SelfTestResults = null; // writable closure var read by the frozen bridge getter
    try {
      const _jv5BridgeObj = {
        version:     '5.11.1',
        // 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,
        get selfTests() { return _jv5SelfTestResults; },
        runSelfTests:  _devModeEnabled ? (() => _runSelfTests()) : undefined,
        // Safe to expose unconditionally — a single boolean, no secrets.
        // Lets JV5 Diagnostics tell "gated by dev mode" apart from "missing/outdated".
        devModeEnabled: () => _devModeEnabled,
        // True if unsafeWindow.fetch/XMLHttpRequest still point at JV5's
        // patched versions (i.e. no other script has re-patched them since).
        // See NetworkInterceptor.isIntact() for context.
        networkPatchIntact: () => { try { return netInterceptor.isIntact(); } catch { return null; } },
        // ── 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.
        // SECURITY: gated behind jv5_dev_mode, same as getLatestText/
        // runSelfTests/verifyAdminSig above — cryptoFlags() reveals which
        // crypto modes are active and pskRoundTrip() exercises the real PSK,
        // neither of which should be callable by arbitrary page-context
        // scripts on a (potentially compromised) janitorai.com by default.
        cryptoFlags: _devModeEnabled ? (() => ({
          argon2Emulation: gget('jv5_use_argon2', true),
          ed25519Admin:    gget('jv5_use_ed25519_admin', true),
          ratchetGlobal:   gget('jv5_ratchet_global', true),
        })) : undefined,
        // 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: _devModeEnabled ? (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 }; }
        }) : undefined,
        // Tests the real SSRF block-list regex against representative inputs
        ssrfBlocked: (url) => _isSSRFBlocked(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.
        // Last few uncaught/reported JV5 runtime errors — see _jvReportError above.
        // SECURITY: stack traces are intentionally omitted here (the full
        // stack is shown to the USER via the toast's tap-to-copy, but isn't
        // handed to arbitrary page-context scripts that can read __jv5).
        errorLog: () => _jvErrorLog.map(({ time, context, message }) => ({ time, context, message })),
        sendLog: () => _sendAttemptLog.slice(),
        // Enable dev mode via browser console: GM_setValue('jv5_dev_mode', true) then reload
      };
      // Freeze the bridge so page-context scripts can't monkey-patch our
      // diagnostic functions or inject new properties onto __jv5.
      Object.freeze(_jv5BridgeObj);
      Object.defineProperty(unsafeWindow, '__jv5', {
        value:        _jv5BridgeObj,
        configurable: false,
        writable:     false,
        enumerable:   true,
      });
    } catch {}
  }

  // ─── INSTALL SECURITY WARNING (shown once on first install) ─────────────────
  // ─── CHANGELOG ────────────────────────────────────────────────────────────
  const _CHANGELOG = [
    {
      version: 'v5.11.1',
      label: 'Security Patch',
      sections: [
        {
          type: 'security',
          icon: '🔒',
          color: '#c4b5fd',
          bg: 'rgba(139,92,246,0.08)',
          border: 'rgba(139,92,246,0.3)',
          title: 'Security Fixes',
          items: [
            'SSRF guard bypass fixed — bracketed IPv6 literals such as http://[fc00::1]/ (ULA) and http://[fe80::1]/ (link-local) slipped past both the URL regex and the IPv4-only hostname parser, since brackets are required for valid IPv6 URLs but the regex\'s IPv6 alternatives assumed no brackets. Added _isPrivateIPv6Hostname() to explicitly check loopback, ULA (fc00::/7), link-local (fe80::/10), IPv4-mapped/translated loopback, and unspecified (::) addresses on the parsed hostname.',
          ],
        },
        {
          type: 'fix',
          icon: '🐛',
          color: '#fbbf24',
          bg: 'rgba(251,191,36,0.07)',
          border: 'rgba(251,191,36,0.25)',
          title: 'Bug Fixes',
          items: [
            '@version header (5.11.0) and the internal "JanitorV5 Ultimate vX.X.X" code comment (stale at v5.7.4) both bumped to 5.11.1 and synced with each other — they had drifted out of sync with the actual changelog version for several releases.',
          ],
        },
      ],
    },
    {
      version: 'v5.11.1',
      label: 'Security & Feature Update',
      sections: [
        {
          type: 'new',
          icon: '✨',
          color: '#6ee7b7',
          bg: 'rgba(16,185,129,0.08)',
          border: 'rgba(16,185,129,0.25)',
          title: 'New Features',
          items: [
            'Strong PIN Generator — one click generates a 14-char cryptographically random PIN or a 4-word passphrase, both using crypto.getRandomValues with zero modulo bias',
            'Recovery Code System — a 24-char backup code is generated every time you set or change a PIN. If you forget your PIN, enter the code to decrypt your key and set a new one',
            'Export / Import Settings — back up all your settings, presets, personas, and encrypted API key to a JSON file. Restores on a fresh install in one click',
          ],
        },
        {
          type: 'security',
          icon: '🔒',
          color: '#c4b5fd',
          bg: 'rgba(139,92,246,0.08)',
          border: 'rgba(139,92,246,0.3)',
          title: 'Security Fixes',
          items: [
            '20 JSON.parse calls on GM storage, network data, and user files replaced with _safeJSONParse — guards against prototype pollution from tampered storage or malicious peers',
            'P2P decrypted payloads now validated as non-null objects before processing — a malicious peer can no longer crash the message handler with a crafted payload',
            'PIN generator charset fixed to exactly 64 chars (was 63, causing modulo bias in character selection). While loop added — old fixed 20-byte buffer had a 21% chance of generating a short PIN',
            'Passphrase word generator switched to rejection sampling — eliminates Uint32 modulo bias across 328-word list',
            'Recovery code generator switched to while loop — old fixed buffer had a rare chance of returning a short code',
            '_storeRecoveryBackup now throws on write failure and verifies persistence — prevents showing a recovery code that was never actually stored',
            'Remote selector URL now shows a visible toast warning and console error whenever the feature is active',
            'GM storage write failures in gset() now logged to console instead of silently dropped',
          ],
        },
        {
          type: 'fix',
          icon: '🐛',
          color: '#fbbf24',
          bg: 'rgba(251,191,36,0.07)',
          border: 'rgba(251,191,36,0.25)',
          title: 'Bug Fixes',
          items: [
            'Security & Legal tab now scrolls on mobile — root cause was a mismatched </div> in the About panel that closed the scroll container early, placing Legal outside it',
            'Tab switching now resets scroll position to top — previously switching from a scrolled tab left the new tab starting mid-content',
            '_pinCheckLockout() ambiguous null return values replaced with named sentinels (PIN_LOCKOUT_NEVER_FAILED / PIN_LOCKOUT_EXPIRED) to prevent future callers mishandling the two distinct states',
            '_makeSelector no longer generates data-index based selectors — Virtuoso lists renumber data-index on scroll, making those selectors immediately stale',
            'Export key list corrected — removed 2 phantom keys never written by the script, added 10 real keys that were missing (ap_forbidden_words, ap_thinking, ms2_asum_auto, jv5_custom_psk_hash, jv4_p2p_admin_hash, and others)',
            'Duplicate words (spore, viper) removed from passphrase word list',
            'Unguarded querySelector().value accesses in settings save handler replaced with optional chaining — prevented undefined being persisted as a setting value if an element was missing',
            '_patchFetch converted to arrow function, _patchXHR self alias renamed to interceptor — eliminates window.self shadowing',
            'Dead code block (empty if statement) removed from _exportSettings',
            'Clipboard copy on mobile now falls back to execCommand when the Clipboard API is blocked — previously the Copy button showed no response and silently failed on many mobile browsers',
            'Auto-summarise failure now shows a toast instead of silently doing nothing — users previously had no idea the background summarise had failed',
            'PSK self-test no longer falsely reports FAIL when PIN is active and locked — the test now skips with a clear note instead of logging a scary crypto error',
            'PSK community chat documentation corrected — the popover previously claimed all users share a common encryption baseline, which has not been true since the per-install GM-half was added in v5.10',
            '.ms2-top-toast CSS class restored — was accidentally deleted in a dead-code removal pass, breaking all P2P chat toast notifications (16 call sites affected)',
            'Version header bumped to 5.11.1 to match the changelog (was still showing 5.10.0)',
            '7 unhandled Promise rejections fixed — .then() without .catch() on P2P key exchange, admin signature verification, PSK report encryption, notification permission, auto-summarise, and two clipboard writes',
            'Dead constants removed: P2P_RELAY (never read after assignment), SVG_ARROW_R, SVG_ARROW_DN (both defined but never referenced)',
            'Dead functions removed: _getApiKey() (redundant with CFG.apiKey getter), _getHealthyRelay() (single-line wrapper around _p2pGetRelay, never called), topToastHTML() (replaced by toastHTML, never called)',
            'Export/Import buttons were bound to click events twice — once in openSettingsModal and once in _rewireGeneralTab — causing each click to fire the handler twice',
            'API key field now shows bullet placeholders (••••••••) instead of an empty input when PIN is locked, preventing users from thinking their key was deleted',
            'Added tip below API key input explaining that clicking save is required to save the key — pressing Enter alone does not persist it',
          ],
        },
      ],
    },
  ];

  // Shows the changelog modal.
  // forceRead: if true, shows 20s countdown + agreement checkboxes (first install).
  //            if false, opens instantly with no checkboxes (manual revisit).
  function _showChangelogModal(forceRead) {
    if (!document.body) return;

    const backdrop = document.createElement('div');
    backdrop.style.cssText = [
      'position:fixed;inset:0;z-index:2147483646;',
      'background:rgba(0,0,0,0.88);',
      'display:flex;align-items:flex-start;justify-content:center;',
      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;',
      'padding:env(safe-area-inset-top,12px) 12px 24px;',
      'overflow-y:auto;-webkit-overflow-scrolling:touch;',
    ].join('');

    const modal = document.createElement('div');
    modal.style.cssText = [
      'background:#111827;border:1px solid rgba(139,92,246,0.4);',
      'border-radius:14px;width:min(480px,100%);',
      'box-shadow:0 20px 50px rgba(0,0,0,0.7);',
      'overflow:hidden;margin:auto;flex-shrink:0;',
    ].join('');

    // Build sections HTML — all changelog entries, newest first
    const _clTagStyle = (color, bg, border) =>
      `display:inline-block;padding:2px 8px;border-radius:20px;font-size:9px;font-weight:700;letter-spacing:.6px;background:${bg};color:${color};border:1px solid ${border};margin-left:8px;vertical-align:middle;text-transform:uppercase;`;
    const sectionsHTML = _CHANGELOG.map((entry, entryIdx) => {
      const isNew = entryIdx === 0;
      const isOld = entryIdx === _CHANGELOG.length - 1 && _CHANGELOG.length > 1;
      const tag = isNew
        ? `<span style="${_clTagStyle('#6ee7b7','rgba(16,185,129,0.15)','rgba(16,185,129,0.35)')}">New</span>`
        : isOld
          ? `<span style="${_clTagStyle('#9ca3af','rgba(107,114,128,0.12)','rgba(107,114,128,0.3)')}">Old</span>`
          : `<span style="${_clTagStyle('#fbbf24','rgba(251,191,36,0.12)','rgba(251,191,36,0.28)')}">Prev</span>`;
      const entrySecs = entry.sections.map(sec => `
        <div style="background:${sec.bg};border:1px solid ${sec.border};border-radius:9px;padding:10px 12px;margin-bottom:10px;">
          <div style="font-size:10.5px;font-weight:700;color:${sec.color};letter-spacing:.5px;margin-bottom:7px;">
            ${sec.icon} ${sec.title.toUpperCase()}
          </div>
          <ul style="margin:0;padding:0 0 0 14px;list-style:disc;">
            ${sec.items.map(item => `
              <li style="font-size:11.5px;color:#d1d5db;line-height:1.65;margin-bottom:4px;">${item}</li>
            `).join('')}
          </ul>
        </div>
      `).join('');
      return `
        <div style="${entryIdx > 0 ? 'margin-top:4px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.08);' : ''}">
          <div style="display:flex;align-items:center;margin-bottom:10px;">
            <span style="font-size:12px;font-weight:700;color:#c4b5fd;">${entry.version} — ${entry.label}</span>
            ${tag}
          </div>
          ${entrySecs}
        </div>
      `;
    }).join('');

    // Agreement checkboxes (first install only)
    const agreementsHTML = forceRead ? `
      <div style="background:rgba(99,102,241,0.07);border:1px solid rgba(99,102,241,0.3);border-radius:9px;padding:12px 14px;margin-bottom:12px;">
        <div style="font-size:10.5px;font-weight:700;color:#818cf8;letter-spacing:.5px;margin-bottom:10px;">
          ✅ BEFORE YOU CONTINUE — CHECK ALL THREE
        </div>
        ${[
          ['ack_perms',   'I understand this script runs with elevated browser permissions and auto-updates without a review step between updates.'],
          ['ack_legal',   'I have read (or will read) the Security &amp; Legal tab and understand what data this script can access.'],
          ['ack_recovery', 'I understand that losing both my PIN and my recovery code, with no export backup, means my encrypted API key cannot be recovered.'],
        ].map(([id, text]) => `
          <label style="display:flex;align-items:flex-start;gap:9px;margin-bottom:8px;cursor:pointer;">
            <input type="checkbox" id="jv5-ack-${id}" style="width:15px;height:15px;margin-top:1px;accent-color:#7c3aed;flex-shrink:0;">
            <span style="font-size:11.5px;color:#d1d5db;line-height:1.6;">${text}</span>
          </label>
        `).join('')}
      </div>
    ` : '';

    // Timer bar (first install only)
    const timerHTML = forceRead ? `
      <div style="margin-bottom:12px;">
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
          <span style="font-size:10.5px;color:#9ca3af;">Please read before closing</span>
          <span id="jv5-cl-countdown" style="font-size:10.5px;font-weight:700;color:#fbbf24;">20s</span>
        </div>
        <div style="background:rgba(255,255,255,0.08);border-radius:4px;height:4px;overflow:hidden;">
          <div id="jv5-cl-bar" style="height:100%;width:0%;background:linear-gradient(90deg,#7c3aed,#6366f1);border-radius:4px;transition:width .25s linear;"></div>
        </div>
      </div>
    ` : '';

    modal.innerHTML = `
      <div style="background:linear-gradient(135deg,rgba(109,40,217,0.2),rgba(79,70,229,0.15));padding:14px 16px 12px;border-bottom:1px solid rgba(139,92,246,0.25);">
        <div style="display:flex;align-items:center;gap:9px;">
          <div style="width:32px;height:32px;background:rgba(109,40,217,0.25);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;">📋</div>
          <div>
            <div style="font-size:13.5px;font-weight:700;color:#c4b5fd;">Release History — ${_CHANGELOG.length} ${_CHANGELOG.length === 1 ? 'entry' : 'entries'}</div>
            <div style="font-size:10.5px;color:#6b7280;margin-top:1px;">JanitorV5 by eivls · Latest: ${_CHANGELOG[0].version}</div>
          </div>
        </div>
      </div>
      <div style="padding:14px 14px 4px;max-height:55vh;overflow-y:auto;-webkit-overflow-scrolling:touch;">
        ${sectionsHTML}
      </div>
      <div style="padding:10px 14px 14px;">
        ${agreementsHTML}
        ${timerHTML}
        <button id="jv5-cl-close" style="
          width:100%;padding:11px;font-size:13px;font-weight:600;
          background:linear-gradient(135deg,#6d28d9,#4f46e5);
          border:none;border-radius:9px;color:#fff;cursor:pointer;
          opacity:${forceRead ? '0.4' : '1'};
          transition:opacity .2s;
          -webkit-tap-highlight-color:transparent;
        " ${forceRead ? 'disabled' : ''}>
          ${forceRead ? 'Please wait and read above…' : '✓ Close'}
        </button>
      </div>
    `;

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

    const closeBtn = modal.querySelector('#jv5-cl-close');

    if (forceRead) {
      // 20-second countdown
      const TOTAL = 20;
      let remaining = TOTAL;
      let allChecked = false;

      const checkboxIds = ['jv5-ack-ack_perms', 'jv5-ack-ack_legal', 'jv5-ack-ack_recovery'];
      const checkboxes  = checkboxIds.map(id => modal.querySelector('#' + id));

      const updateBtn = () => {
        const ready = remaining <= 0 && allChecked;
        closeBtn.disabled = !ready;
        closeBtn.style.opacity = ready ? '1' : '0.4';
        closeBtn.textContent = ready
          ? "✓ I've read this — Close"
          : remaining > 0
            ? `Please wait ${remaining}s and check all boxes…`
            : 'Check all boxes above to continue';
      };

      checkboxes.forEach(cb => cb?.addEventListener('change', () => {
        allChecked = checkboxes.every(c => c?.checked);
        updateBtn();
      }));

      const bar = modal.querySelector('#jv5-cl-bar');
      const countdown = modal.querySelector('#jv5-cl-countdown');
      const tick = setInterval(() => {
        remaining--;
        const pct = Math.round(((TOTAL - remaining) / TOTAL) * 100);
        if (bar) bar.style.width = pct + '%';
        if (countdown) countdown.textContent = remaining > 0 ? remaining + 's' : 'Done';
        if (remaining <= 0) clearInterval(tick);
        updateBtn();
      }, 1000);
    }

    const doClose = () => {
      if (forceRead) {
        // Mark as acknowledged so this never shows again as first-install
        try { GM_setValue('jv5_install_ack', true); } catch {}
      }
      backdrop.remove();
    };

    closeBtn.addEventListener('click', doClose);
    // No backdrop-click dismiss on forceRead — they must interact
    if (!forceRead) {
      backdrop.addEventListener('click', e => { if (e.target === backdrop) doClose(); });
      document.addEventListener('keydown', function esc(e) {
        if (e.key === 'Escape') { doClose(); document.removeEventListener('keydown', esc); }
      });
    }
  }

  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();
      // After the security warning, show the first-install changelog + agreements
      // if the user hasn't acknowledged it yet. Small delay so the warning fade
      // completes before the new modal opens.
      const alreadyAcked = (() => { try { return GM_getValue('jv5_install_ack', false); } catch { return false; } })();
      if (!alreadyAcked) {
        setTimeout(() => _showChangelogModal(true), 350);
      }
    };
    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);
  }

})();