JanitorAI - Macro Processor Library

Macro processing library for JanitorAI. Replaces {{macro}} placeholders in text with computed values. Supports dice rolls, random picks, date/time, and utility macros.

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/573103/1794114/JanitorAI%20-%20Macro%20Processor%20Library.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         JanitorAI - Macro Processor Library
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.0.0
// @description  Macro processing library for JanitorAI. Replaces {{macro}} placeholders in text with computed values. Supports dice rolls, random picks, date/time, and utility macros.
// @author       ZephyrThePink - @ZephyrThePink on Discord
// @match        https://janitorai.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

/**
 * ╔══════════════════════════════════════════════════════════════╗
 * ║             JanitorAI  Macro Processor Library              ║
 * ╠══════════════════════════════════════════════════════════════╣
 * ║                                                              ║
 * ║  Processes {{macro}} placeholders in any text string.        ║
 * ║  Designed as a library consumed by the QoL Plugins script   ║
 * ║  or called directly from the browser console.               ║
 * ║                                                              ║
 * ║  Macros are case-insensitive, whitespace-tolerant, and      ║
 * ║  support nesting (inner macros resolve first).              ║
 * ║                                                              ║
 * ╠══════════════════════════════════════════════════════════════╣
 * ║  ARGUMENT SEPARATORS                                        ║
 * ║                                                              ║
 * ║  Double colon (preferred):  {{roll::2d6+3}}                 ║
 * ║  Space (single arg):       {{roll 2d6+3}}                   ║
 * ║  Single colon (legacy):    {{roll:2d6+3}}                   ║
 * ║                                                              ║
 * ╠══════════════════════════════════════════════════════════════╣
 * ║  AVAILABLE MACROS                                           ║
 * ║                                                              ║
 * ║  ── Randomization ────────────────────────────────────────  ║
 * ║  {{random::a::b::c}}          Random pick (re-rolls each    ║
 * ║                                time process() is called)    ║
 * ║  {{pick::a::b::c}}            Stable random pick (same      ║
 * ║                                result for same seed+options)║
 * ║  {{roll::NdS}}                Dice roll (e.g. 1d20, 2d6+3, ║
 * ║                                4d8-2). N ≤ 100, S ≤ 1000   ║
 * ║                                                              ║
 * ║  ── Date & Time ──────────────────────────────────────────  ║
 * ║  {{time}}                     Local time (toLocaleTimeString)║
 * ║  {{time::UTC+N}} / {{time::UTC-N}}                          ║
 * ║                                Time at UTC offset (e.g.     ║
 * ║                                UTC+5, UTC-3, UTC+5:30)      ║
 * ║  {{date}}                     Local date (short)            ║
 * ║  {{weekday}}                  Day of the week (full name)   ║
 * ║  {{isotime}}                  Time as HH:mm                 ║
 * ║  {{isodate}}                  Date as YYYY-MM-DD            ║
 * ║  {{datetimeformat::FMT}}      Custom format string          ║
 * ║    Tokens: YYYY YY MMMM MMM MM M dddd ddd DD D             ║
 * ║            HH H hh h mm m ss s A a                          ║
 * ║                                                              ║
 * ║  ── Math ─────────────────────────────────────────────────  ║
 * ║  {{abs::N}}                   Absolute value                ║
 * ║  {{ceil::N}}                  Round up                      ║
 * ║  {{floor::N}}                 Round down                    ║
 * ║  {{round::N}}                 Round to nearest integer      ║
 * ║  {{min::a::b[::c...]}}        Minimum of values            ║
 * ║  {{max::a::b[::c...]}}        Maximum of values            ║
 * ║  {{calc::expression}}         Evaluate a math expression    ║
 * ║    Supports: + - * / % ** ( )                               ║
 * ║    e.g. {{calc::2 + 3 * 4}}  → 14                          ║
 * ║                                                              ║
 * ║  ── Utility ──────────────────────────────────────────────  ║
 * ║  {{newline}}                  Single newline (\n)           ║
 * ║  {{newline::N}}               N newlines (max 100)          ║
 * ║  {{space}}                    Single space                  ║
 * ║  {{space::N}}                 N spaces (max 100)            ║
 * ║  {{trim}}                     Strip surrounding whitespace  ║
 * ║  {{reverse::text}}            Reverse a string              ║
 * ║  {{noop}}                     Empty string (no output)      ║
 * ║  {{// comment}}               Comment (stripped from output)║
 * ║  {{//}}...{{///}}             Block comment (stripped)       ║
 * ║  {{length::text}}             Character count of text       ║
 * ║  {{lowercase::text}}          Convert text to lowercase     ║
 * ║  {{uppercase::text}}          Convert text to UPPERCASE     ║
 * ║  {{capitalize::text}}         Capitalize First Letter       ║
 * ║  {{replace::find::replace::text}}                           ║
 * ║                                Replace first occurrence     ║
 * ║  {{replaceall::find::replace::text}}                        ║
 * ║                                Replace all occurrences      ║
 * ║  {{substr::start::length::text}}                            ║
 * ║                                Substring extraction         ║
 * ║  {{input}}                     Current chat input field     ║
 * ║                                                              ║
 * ║  ── Tier 2: Message Macros (require Message Logger) ──────  ║
 * ║  Auto-registered when JaiMessageLogger library is detected. ║
 * ║                                                              ║
 * ║  {{lastMessage}}              Last message text (any role)  ║
 * ║  {{lastUserMessage}}          Last user message text        ║
 * ║  {{lastCharMessage}}          Last bot message text         ║
 * ║  {{lastBotMessage}}           Alias for lastCharMessage     ║
 * ║  {{lastMessageId}}            Index of the last message     ║
 * ║  {{currentSwipeId}}           Current swipe (1-based)       ║
 * ║  {{lastSwipeId}}              Same as currentSwipeId        ║
 * ║  {{messageCount}}             Total messages in chat        ║
 * ║  {{botStatus}}                Bot status (Complete/         ║
 * ║                                Streaming/Editing)           ║
 * ║  {{userStatus}}               User status (Complete/Editing)║
 * ║                                                              ║
 * ╠══════════════════════════════════════════════════════════════╣
 * ║  USAGE (from another userscript or console)                 ║
 * ║                                                              ║
 * ║  // Basic:                                                   ║
 * ║  QoLMacros.process("Roll: {{roll::1d20}}")                  ║
 * ║  // → "Roll: 17"                                            ║
 * ║                                                              ║
 * ║  // With context (seed for {{pick}}):                       ║
 * ║  QoLMacros.process("{{pick::a::b::c}}", { seed: "chat123"})║
 * ║  // → "b"  (always the same for this seed + options)        ║
 * ║                                                              ║
 * ║  // Nested macros:                                          ║
 * ║  QoLMacros.process("{{random::{{roll::1d6}}::fixed}}")      ║
 * ║  // → inner resolves first, then outer picks between the    ║
 * ║  //   roll result and "fixed"                               ║
 * ║                                                              ║
 * ║  // Register a custom macro:                                ║
 * ║  QoLMacros.register("greet", (args, ctx) => "Hello!")       ║
 * ║                                                              ║
 * ║  // List all macros:                                        ║
 * ║  QoLMacros.list()                                           ║
 * ║                                                              ║
 * ║  // Demo all macros:                                        ║
 * ║  QoLMacros.demo()                                           ║
 * ║                                                              ║
 * ╠══════════════════════════════════════════════════════════════╣
 * ║  API REFERENCE  (window.QoLMacros)                          ║
 * ║                                                              ║
 * ║  .ready               → true once the library has loaded    ║
 * ║  .version             → library version string              ║
 * ║                                                              ║
 * ║  .process(text, ctx?) → string                              ║
 * ║      Process all macros in text. Optional context object:   ║
 * ║      { seed: string } — seed for {{pick}} stability         ║
 * ║                                                              ║
 * ║  .register(name, fn)  → void                                ║
 * ║      Register a custom macro handler.                       ║
 * ║      fn(args: string[], ctx: object) → string               ║
 * ║                                                              ║
 * ║  .unregister(name)    → boolean                             ║
 * ║      Remove a macro handler. Returns true if it existed.    ║
 * ║                                                              ║
 * ║  .has(name)           → boolean                             ║
 * ║      Check if a macro handler exists.                       ║
 * ║                                                              ║
 * ║  .list()              → string[]                            ║
 * ║      List all registered macro names.                       ║
 * ║                                                              ║
 * ║  .demo()              → void                                ║
 * ║      Run and log a demo of every built-in macro.            ║
 * ║                                                              ║
 * ║  .messageLogger        → object|null                        ║
 * ║      Reference to JaiMessageLogger if connected, else null. ║
 * ║                                                              ║
 * ║  .tier2Connected       → boolean                            ║
 * ║      True if Message Logger was detected and Tier 2 macros  ║
 * ║      are active.                                            ║
 * ║                                                              ║
 * ╚══════════════════════════════════════════════════════════════╝
 */

(function () {
    'use strict';

    // ================================================================
    // === ARGUMENT PARSING ===
    // ================================================================

    /**
     * Parse a raw macro body into a name and arguments array.
     *
     * Separator priority:
     *   1. Double colon `::` (multi-arg)
     *   2. Space (single arg, first space only)
     *   3. Single colon `:` (legacy, single arg)
     *
     * @param {string} raw - The text between {{ and }}
     * @returns {{ name: string, args: string[] }}
     */
    function parseMacroCall(raw) {
        const trimmed = raw.trim();

        // Double colon separator → split into [name, arg1, arg2, ...]
        if (trimmed.includes('::')) {
            const parts = trimmed.split('::');
            return {
                name: parts[0].trim().toLowerCase(),
                args: parts.slice(1).map(a => a.trim())
            };
        }

        // Space separator → name + single arg (everything after first space)
        const spaceIdx = trimmed.indexOf(' ');
        if (spaceIdx !== -1) {
            return {
                name: trimmed.substring(0, spaceIdx).trim().toLowerCase(),
                args: [trimmed.substring(spaceIdx + 1).trim()]
            };
        }

        // Single colon separator (legacy) → name + single arg
        const colonIdx = trimmed.indexOf(':');
        if (colonIdx !== -1) {
            return {
                name: trimmed.substring(0, colonIdx).trim().toLowerCase(),
                args: [trimmed.substring(colonIdx + 1).trim()]
            };
        }

        // No arguments
        return { name: trimmed.toLowerCase(), args: [] };
    }

    // ================================================================
    // === DICE ROLLER ===
    // ================================================================

    /**
     * Parse and evaluate dice notation.
     * Supports: NdS, NdS+M, NdS-M
     *
     * Constraints: N ≤ 100, S ≤ 1000, |M| ≤ 10000
     *
     * @param {string} notation - e.g. "2d6+3"
     * @returns {number|NaN}
     */
    function rollDice(notation) {
        const match = notation.trim().match(/^(\d+)\s*d\s*(\d+)\s*([+-]\s*\d+)?$/i);
        if (!match) return NaN;

        const count = parseInt(match[1], 10);
        const sides = parseInt(match[2], 10);
        const modifier = match[3] ? parseInt(match[3].replace(/\s/g, ''), 10) : 0;

        if (count < 1 || count > 100 || sides < 1 || sides > 1000 || Math.abs(modifier) > 10000) {
            return NaN;
        }

        let total = 0;
        for (let i = 0; i < count; i++) {
            total += Math.floor(Math.random() * sides) + 1;
        }
        return total + modifier;
    }

    // ================================================================
    // === STABLE RANDOM (seeded PRNG for {{pick}}) ===
    // ================================================================

    /**
     * Simple deterministic 32-bit hash (djb2 variant).
     * @param {string} str
     * @returns {number}
     */
    function hashString(str) {
        let hash = 5381;
        for (let i = 0; i < str.length; i++) {
            hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
        }
        return hash;
    }

    /**
     * Mulberry32 — fast, single-state 32-bit PRNG.
     * Returns a float in [0, 1).
     * @param {number} seed
     * @returns {number}
     */
    function mulberry32(seed) {
        let t = (seed + 0x6D2B79F5) | 0;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    }

    // ================================================================
    // === SAFE MATH EVALUATOR ===
    // ================================================================

    /**
     * Evaluate a simple math expression with +, -, *, /, %, **, and parentheses.
     * Only allows digits, operators, whitespace, parens, and decimal points.
     * No variables, no function calls — safe from injection.
     *
     * @param {string} expr
     * @returns {number|NaN}
     */
    function safeMathEval(expr) {
        // Strip whitespace for validation
        const cleaned = expr.replace(/\s+/g, '');

        // Strict whitelist: digits, decimal points, operators, parens
        if (!/^[0-9+\-*/%().]+$/.test(cleaned) && !/^[0-9+\-*/%().\s*]+$/.test(cleaned)) {
            // Also allow ** (exponentiation)
            if (!/^[0-9+\-*/%(). ]+$/.test(expr.replace(/\*\*/g, '^'))) {
                return NaN;
            }
        }

        // Block anything that looks like it could be code
        if (/[a-zA-Z_$]/.test(cleaned)) return NaN;
        // Block assignment, bitwise, comparison, etc.
        if (/[=&|^~!<>?:;,{}[\]\\@#`'"]/.test(cleaned)) return NaN;
        // Block empty parens (function call attempt)
        if (/\(\)/.test(cleaned)) return NaN;

        try {
            // Use Function constructor in a limited context (no access to scope)
            // The expression has already been validated to contain only safe characters.
            const fn = new Function(`"use strict"; return (${expr});`);
            const result = fn();
            if (typeof result !== 'number' || !isFinite(result)) return NaN;
            return result;
        } catch {
            return NaN;
        }
    }

    // ================================================================
    // === DATE / TIME FORMATTING ===
    // ================================================================

    /**
     * Format a Date using a token-based format string.
     *
     * Tokens (replaced longest-first to avoid partial matches):
     *   YYYY  → 4-digit year         YY    → 2-digit year
     *   MMMM  → month name (full)    MMM   → month name (short)
     *   MM    → month (01-12)        M     → month (1-12)
     *   dddd  → weekday (full)       ddd   → weekday (short)
     *   DD    → day (01-31)          D     → day (1-31)
     *   HH    → hours 24h (00-23)    H     → hours 24h (0-23)
     *   hh    → hours 12h (01-12)    h     → hours 12h (1-12)
     *   mm    → minutes (00-59)      m     → minutes (0-59)
     *   ss    → seconds (00-59)      s     → seconds (0-59)
     *   A     → AM/PM               a     → am/pm
     *
     * @param {string} format
     * @param {Date} [date]
     * @returns {string}
     */
    function formatDateTime(format, date) {
        date = date || new Date();

        const pad = (n) => String(n).padStart(2, '0');

        const h24 = date.getHours();
        const h12 = h24 % 12 || 12;
        const ampm = h24 < 12 ? 'AM' : 'PM';

        const daysFull  = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
        const daysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
        const moFull    = ['January','February','March','April','May','June',
                           'July','August','September','October','November','December'];
        const moShort   = ['Jan','Feb','Mar','Apr','May','Jun',
                           'Jul','Aug','Sep','Oct','Nov','Dec'];

        const tokens = {
            'YYYY': date.getFullYear(),
            'YY':   String(date.getFullYear()).slice(-2),
            'MMMM': moFull[date.getMonth()],
            'MMM':  moShort[date.getMonth()],
            'MM':   pad(date.getMonth() + 1),
            'M':    date.getMonth() + 1,
            'dddd': daysFull[date.getDay()],
            'ddd':  daysShort[date.getDay()],
            'DD':   pad(date.getDate()),
            'D':    date.getDate(),
            'HH':   pad(h24),
            'H':    h24,
            'hh':   pad(h12),
            'h':    h12,
            'mm':   pad(date.getMinutes()),
            'm':    date.getMinutes(),
            'ss':   pad(date.getSeconds()),
            's':    date.getSeconds(),
            'A':    ampm,
            'a':    ampm.toLowerCase(),
        };

        // Replace longest tokens first
        let result = format;
        const sorted = Object.keys(tokens).sort((a, b) => b.length - a.length);
        for (const tok of sorted) {
            result = result.split(tok).join(String(tokens[tok]));
        }
        return result;
    }

    /**
     * Parse a UTC offset string like "UTC+5", "UTC-3:30", "UTC+5:30".
     * Returns a Date shifted by the offset, or null on failure.
     *
     * @param {string} offsetStr
     * @returns {Date|null}
     */
    function dateWithUTCOffset(offsetStr) {
        const match = offsetStr.trim().match(/^UTC\s*([+-])\s*(\d{1,2})(?::(\d{2}))?$/i);
        if (!match) return null;

        const sign    = match[1] === '+' ? 1 : -1;
        const hours   = parseInt(match[2], 10);
        const minutes = match[3] ? parseInt(match[3], 10) : 0;

        if (hours > 14 || minutes > 59) return null;

        const offsetMs = sign * (hours * 3600000 + minutes * 60000);
        const now      = new Date();
        const utcMs    = now.getTime() + now.getTimezoneOffset() * 60000;
        return new Date(utcMs + offsetMs);
    }

    // ================================================================
    // === TRIM SENTINEL ===
    // ================================================================

    // Internal sentinel that {{trim}} expands to; post-processing collapses
    // whitespace around it and removes the sentinel itself.
    const TRIM_SENTINEL = '\x00__QOLMACRO_TRIM__\x00';

    // ================================================================
    // === MACRO HANDLERS ===
    // ================================================================

    /** @type {Object<string, (args: string[], ctx: object) => string>} */
    const handlers = {};

    // ────────────────────────────────────────────────────────────
    // Randomization
    // ────────────────────────────────────────────────────────────

    handlers['random'] = (args) => {
        if (args.length === 0) return '';
        return args[Math.floor(Math.random() * args.length)];
    };

    handlers['pick'] = (args, ctx) => {
        if (args.length === 0) return '';
        // Build a seed from: context seed + options + occurrence index
        const seedStr = (ctx.seed || 'default') + '|' + args.join('|') + '|' + (ctx.macroIndex || 0);
        const hash = hashString(seedStr);
        const idx = Math.floor(mulberry32(Math.abs(hash)) * args.length);
        return args[idx];
    };

    handlers['roll'] = (args) => {
        const notation = args[0] || '1d6';
        const result = rollDice(notation);
        return isNaN(result) ? `[Invalid dice: ${notation}]` : String(result);
    };

    // ────────────────────────────────────────────────────────────
    // Date & Time
    // ────────────────────────────────────────────────────────────

    handlers['time'] = (args) => {
        if (args.length > 0) {
            const shifted = dateWithUTCOffset(args[0]);
            if (shifted) return shifted.toLocaleTimeString();
            return `[Invalid offset: ${args[0]}]`;
        }
        return new Date().toLocaleTimeString();
    };

    handlers['date'] = () => new Date().toLocaleDateString();

    handlers['weekday'] = () => {
        return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][new Date().getDay()];
    };

    handlers['isotime'] = () => {
        const d = new Date();
        return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
    };

    handlers['isodate'] = () => {
        const d = new Date();
        return d.getFullYear()
            + '-' + String(d.getMonth() + 1).padStart(2, '0')
            + '-' + String(d.getDate()).padStart(2, '0');
    };

    handlers['datetimeformat'] = (args) => {
        if (args.length === 0) return new Date().toISOString();
        return formatDateTime(args[0]);
    };

    // ────────────────────────────────────────────────────────────
    // Math
    // ────────────────────────────────────────────────────────────

    handlers['abs'] = (args) => {
        const n = parseFloat(args[0]);
        return isNaN(n) ? '[Invalid number]' : String(Math.abs(n));
    };

    handlers['ceil'] = (args) => {
        const n = parseFloat(args[0]);
        return isNaN(n) ? '[Invalid number]' : String(Math.ceil(n));
    };

    handlers['floor'] = (args) => {
        const n = parseFloat(args[0]);
        return isNaN(n) ? '[Invalid number]' : String(Math.floor(n));
    };

    handlers['round'] = (args) => {
        const n = parseFloat(args[0]);
        return isNaN(n) ? '[Invalid number]' : String(Math.round(n));
    };

    handlers['min'] = (args) => {
        const nums = args.map(Number).filter(n => !isNaN(n));
        return nums.length === 0 ? '[No valid numbers]' : String(Math.min(...nums));
    };

    handlers['max'] = (args) => {
        const nums = args.map(Number).filter(n => !isNaN(n));
        return nums.length === 0 ? '[No valid numbers]' : String(Math.max(...nums));
    };

    handlers['calc'] = (args) => {
        const expr = args.join('::'); // rejoin in case :: was part of expression (unlikely but safe)
        const result = safeMathEval(expr);
        return isNaN(result) ? `[Invalid expression: ${expr}]` : String(result);
    };

    // ────────────────────────────────────────────────────────────
    // Utility
    // ────────────────────────────────────────────────────────────

    handlers['newline'] = (args) => {
        const count = Math.min(Math.max(parseInt(args[0], 10) || 1, 1), 100);
        return '\n'.repeat(count);
    };

    handlers['space'] = (args) => {
        const count = Math.min(Math.max(parseInt(args[0], 10) || 1, 1), 100);
        return ' '.repeat(count);
    };

    handlers['trim'] = () => TRIM_SENTINEL;

    handlers['reverse'] = (args) => {
        return (args[0] || '').split('').reverse().join('');
    };

    handlers['noop'] = () => '';

    handlers['//'] = () => '';  // Inline comment

    handlers['length'] = (args) => {
        return String((args[0] || '').length);
    };

    handlers['lowercase'] = (args) => {
        return (args[0] || '').toLowerCase();
    };

    handlers['uppercase'] = (args) => {
        return (args[0] || '').toUpperCase();
    };

    handlers['capitalize'] = (args) => {
        const text = args[0] || '';
        return text.charAt(0).toUpperCase() + text.slice(1);
    };

    handlers['replace'] = (args) => {
        // {{replace::find::replacement::text}}
        if (args.length < 3) return args[args.length - 1] || '';
        const [find, replacement, ...rest] = args;
        const text = rest.join('::');
        return text.replace(find, replacement);
    };

    handlers['replaceall'] = (args) => {
        // {{replaceall::find::replacement::text}}
        if (args.length < 3) return args[args.length - 1] || '';
        const [find, replacement, ...rest] = args;
        const text = rest.join('::');
        return text.split(find).join(replacement);
    };

    handlers['substr'] = (args) => {
        // {{substr::start::length::text}}
        if (args.length < 3) return '';
        const start  = parseInt(args[0], 10) || 0;
        const length = parseInt(args[1], 10);
        const text   = args.slice(2).join('::');
        if (isNaN(length)) return text.substring(start);
        return text.substring(start, start + length);
    };

    handlers['input'] = () => {
        const textarea = document.querySelector('textarea[class*="_chatInput_"], textarea[class*="_autoResize"], [class*="_inputArea_"] textarea');
        return textarea ? textarea.value : '';
    };

    // ================================================================
    // === TIER 2: MESSAGE LOGGER INTEGRATION ===
    // ================================================================

    let messageLoggerRef = null;

    /**
     * Register Tier 2 macros that depend on JaiMessageLogger.
     * Called once when the Message Logger library is detected.
     *
     * @param {object} logger - The JaiMessageLogger API object
     */
    function connectMessageLogger(logger) {
        if (messageLoggerRef) return; // already connected
        messageLoggerRef = logger;

        // {{lastMessage}} — last message text regardless of role (highest index)
        handlers['lastmessage'] = () => {
            const botIdx  = logger.bot.getIndex();
            const userIdx = logger.user.getIndex();
            if (botIdx < 0 && userIdx < 0) return '';
            if (userIdx > botIdx) return logger.user.getText();
            return logger.bot.getText();
        };

        // {{lastUserMessage}} — last user message text
        handlers['lastusermessage'] = () => logger.user.getText() || '';

        // {{lastCharMessage}} / {{lastBotMessage}} — last bot message text
        handlers['lastcharmessage'] = () => logger.bot.getText() || '';
        handlers['lastbotmessage']  = () => logger.bot.getText() || '';

        // {{lastMessageId}} — index of the most recent message (bot or user)
        handlers['lastmessageid'] = () => {
            const botIdx  = logger.bot.getIndex();
            const userIdx = logger.user.getIndex();
            return String(Math.max(botIdx, userIdx));
        };

        // {{currentSwipeId}} / {{lastSwipeId}} — current swipe index (1-based)
        handlers['currentswipeid'] = () => {
            const idx = logger.bot.getSwipeIndex();
            return idx >= 0 ? String(idx + 1) : '1';
        };
        handlers['lastswipeid'] = handlers['currentswipeid'];

        // {{messageCount}} — total messages in the DOM
        handlers['messagecount'] = () => String(logger.getMessageCount());

        // {{botStatus}} — bot status (Complete/Streaming/Editing)
        handlers['botstatus'] = () => logger.bot.getStatus() || '';

        // {{userStatus}} — user status (Complete/Editing)
        handlers['userstatus'] = () => logger.user.getStatus() || '';

        console.log(
            '%c[QoL Macros]%c Tier 2 connected — %c9 message macros%c registered via JaiMessageLogger',
            'color: #a78bfa; font-weight: bold',
            'color: inherit',
            'color: #34d399; font-weight: bold',
            'color: inherit'
        );
    }

    /**
     * Attempt to detect JaiMessageLogger. Checks the global immediately,
     * and also listens for the ready event in case it loads later.
     */
    function detectMessageLogger() {
        const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

        // Already available?
        if (win.JaiMessageLogger && win.JaiMessageLogger.ready) {
            connectMessageLogger(win.JaiMessageLogger);
            return;
        }

        // Listen for the ready event (Message Logger fires this on load)
        window.addEventListener('jai-msglogger:ready', function onReady(e) {
            if (e.detail && e.detail.ready) {
                connectMessageLogger(e.detail);
            }
            window.removeEventListener('jai-msglogger:ready', onReady);
        });
    }

    // ================================================================
    // === BLOCK COMMENT STRIPPER ===
    // ================================================================

    /**
     * Remove block comments: {{//}}...{{///}}
     * Must run BEFORE the main macro loop (these aren't standard macros).
     * @param {string} text
     * @returns {string}
     */
    function stripBlockComments(text) {
        // Match {{//}} ... {{///}} (with optional whitespace inside braces)
        return text.replace(/\{\{\s*\/\/\s*\}\}[\s\S]*?\{\{\s*\/\/\/\s*\}\}/g, '');
    }

    // ================================================================
    // === MAIN PROCESSOR ===
    // ================================================================

    /**
     * Process all {{macro}} placeholders in text.
     *
     * Resolution order:
     *   1. Strip block comments  {{//}}...{{///}}
     *   2. Iteratively resolve innermost macros first (no {{ inside them)
     *   3. Post-process: collapse whitespace around {{trim}} sentinels
     *
     * Unknown macros are left as-is so that JanitorAI's native
     * {{user}} / {{char}} replacement still works downstream.
     *
     * @param {string} text - Input text with {{macro}} placeholders
     * @param {object} [context] - Optional context
     * @param {string} [context.seed] - Seed string for {{pick}} stability
     * @returns {string} Processed text
     */
    function process(text, context) {
        if (!text || typeof text !== 'string') return text || '';

        context = context || {};

        // Step 1: Strip block comments
        text = stripBlockComments(text);

        // Step 2: Iteratively resolve innermost macros
        const MAX_ITERATIONS = 20;
        let macroIndex = 0;

        for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
            // Match innermost macros: {{ ... }} where ... contains no { or }
            const regex = /\{\{\s*([^{}]+?)\s*\}\}/g;
            let match;
            let found = false;
            let result = '';
            let lastIndex = 0;

            while ((match = regex.exec(text)) !== null) {
                found = true;
                const fullMatch = match[0];
                const inner = match[1]; // already trimmed by the \s* in regex

                let replacement;

                // Check for inline comment (starts with //)
                if (inner.trimStart().startsWith('//')) {
                    replacement = '';
                } else {
                    const parsed = parseMacroCall(inner);
                    const handler = handlers[parsed.name];

                    if (handler) {
                        replacement = handler(parsed.args, { ...context, macroIndex });
                        macroIndex++;
                    } else {
                        // Unknown macro — leave untouched for downstream processing
                        replacement = fullMatch;
                    }
                }

                result += text.substring(lastIndex, match.index) + replacement;
                lastIndex = match.index + fullMatch.length;
            }

            if (!found) break;

            result += text.substring(lastIndex);

            // If nothing changed (all unknown macros left as-is), stop
            if (result === text) break;

            text = result;
        }

        // Step 3: Post-process trim sentinels
        // The sentinel collapses surrounding whitespace (spaces, tabs, newlines)
        const trimRe = new RegExp('\\s*' + escapeRegex(TRIM_SENTINEL) + '\\s*', 'g');
        text = text.replace(trimRe, '');

        return text;
    }

    /** Escape a string for use in a RegExp. */
    function escapeRegex(str) {
        return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    // ================================================================
    // === DEMO ===
    // ================================================================

    function demo() {
        const examples = [
            ['Random pick',            '{{random::apple::banana::cherry}}'],
            ['Stable pick',            '{{pick::red::green::blue}}'],
            ['Dice roll 1d20',         '{{roll::1d20}}'],
            ['Dice roll 2d6+3',        '{{roll::2d6+3}}'],
            ['Dice roll 4d8-2',        '{{roll::4d8-2}}'],
            ['Dice (space sep)',       '{{roll 1d20}}'],
            ['Time',                   '{{time}}'],
            ['Time UTC+9',             '{{time::UTC+9}}'],
            ['Time UTC-5',             '{{time::UTC-5}}'],
            ['Date',                   '{{date}}'],
            ['Weekday',                '{{weekday}}'],
            ['ISO time',               '{{isotime}}'],
            ['ISO date',               '{{isodate}}'],
            ['Custom format',          '{{datetimeformat::YYYY-MM-DD HH:mm:ss}}'],
            ['Custom format 2',        '{{datetimeformat::dddd, MMMM D, YYYY h:mm A}}'],
            ['Newline',                'before{{newline}}after'],
            ['3 Newlines',             'top{{newline::3}}bottom'],
            ['Space',                  'a{{space::5}}b'],
            ['Trim',                   '   hello   {{trim}}   world   '],
            ['Reverse',                '{{reverse::Hello World}}'],
            ['Noop',                   'visible{{noop}}text'],
            ['Comment',                'before{{// this is removed}}after'],
            ['Block comment',          'before{{//}}this is all removed{{///}}after'],
            ['Nested macros',          '{{random::{{roll::1d6}}::fixed}}'],
            ['Math: abs',              '{{abs::-42}}'],
            ['Math: ceil',             '{{ceil::3.2}}'],
            ['Math: floor',            '{{floor::3.8}}'],
            ['Math: round',            '{{round::3.5}}'],
            ['Math: min',              '{{min::5::3::9::1}}'],
            ['Math: max',              '{{max::5::3::9::1}}'],
            ['Math: calc',             '{{calc::2 + 3 * 4}}'],
            ['Math: calc parens',      '{{calc::(2 + 3) * 4}}'],
            ['Length',                  '{{length::hello world}}'],
            ['Lowercase',              '{{lowercase::HELLO}}'],
            ['Uppercase',              '{{uppercase::hello}}'],
            ['Capitalize',             '{{capitalize::hello world}}'],
            ['Replace',                '{{replace::world::Earth::hello world}}'],
            ['Replace all',            '{{replaceall::o::0::foo boo moo}}'],
            ['Substr',                 '{{substr::0::5::Hello World}}'],
            ['Unknown (left as-is)',   '{{user}} said {{char}}'],
            ['Input field',            '{{input}}'],
        ];

        // Tier 2 demos (only if Message Logger connected)
        if (messageLoggerRef) {
            examples.push(
                ['Last message',           '{{lastMessage}}'],
                ['Last user msg',          '{{lastUserMessage}}'],
                ['Last bot msg',           '{{lastCharMessage}}'],
                ['Last message ID',        '{{lastMessageId}}'],
                ['Current swipe',          '{{currentSwipeId}}'],
                ['Message count',          '{{messageCount}}'],
                ['Bot status',             '{{botStatus}}'],
                ['User status',            '{{userStatus}}'],
            );
        }

        console.group('%c[QoL Macros] Demo', 'color: #a78bfa; font-weight: bold');
        const rows = [];
        for (const [label, input] of examples) {
            const output = process(input, { seed: 'demo' });
            rows.push({ Macro: label, Input: input, Output: output });
        }
        console.table(rows);
        console.groupEnd();
    }

    // ================================================================
    // === PUBLIC API ===
    // ================================================================

    const API = {
        ready: true,
        version: '1.0.0',

        /** Process all macros in text. */
        process,

        /** Register a custom macro handler: fn(args, ctx) → string */
        register(name, fn) {
            if (typeof name !== 'string' || typeof fn !== 'function') {
                console.error('[QoL Macros] register() requires (string, function)');
                return;
            }
            handlers[name.toLowerCase()] = fn;
        },

        /** Remove a macro handler. Returns true if it existed. */
        unregister(name) {
            const key = name.toLowerCase();
            if (handlers[key]) {
                delete handlers[key];
                return true;
            }
            return false;
        },

        /** Check if a macro handler exists. */
        has(name) {
            return !!handlers[name.toLowerCase()];
        },

        /** List all registered macro names. */
        list() {
            return Object.keys(handlers);
        },

        /** Run a demo of all built-in macros. */
        demo,

        /** Reference to JaiMessageLogger if connected, else null. */
        get messageLogger() { return messageLoggerRef; },

        /** True if Tier 2 message macros are active. */
        get tier2Connected() { return messageLoggerRef !== null; },
    };

    // ================================================================
    // === FETCH INTERCEPTOR (message macro replacement) ===
    // ================================================================

    /**
     * Quick check: does text contain anything that looks like a known macro?
     * Avoids JSON-parsing overhead on requests with no macros.
     */
    const MACRO_DETECT_RE = /\{\{[^{}]+\}\}/;

    /**
     * Extract chat_id from the current URL (/chats/<id>).
     * @returns {string|null}
     */
    function getChatIdFromUrl() {
        const match = window.location.pathname.match(/\/chats\/(\d+)/);
        return match ? match[1] : null;
    }

    // ================================================================
    // === TEXTAREA INTERCEPTOR (user messages) ===
    // ================================================================

    /**
     * For user messages: replace macros directly in the chat textarea
     * BEFORE the site reads it. This ensures React state, the XHR body,
     * the displayed message, and generateAlpha all see the resolved text.
     *
     * Uses capture-phase listeners so we fire before the site's handlers.
     */
    function initTextareaInterceptor() {
        const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

        // React needs the native setter to properly update controlled components
        const nativeTextareaSetter = Object.getOwnPropertyDescriptor(
            target.HTMLTextAreaElement.prototype, 'value'
        ).set;

        function getTextarea() {
            return document.querySelector(
                'textarea[class*="_chatTextarea_"], textarea[class*="_chatInput_"]'
            );
        }

        function processMacrosInTextarea() {
            const textarea = getTextarea();
            if (!textarea) return false;

            const text = textarea.value;
            if (!text || !MACRO_DETECT_RE.test(text)) return false;

            const chatId = getChatIdFromUrl();
            const ctx = chatId ? { seed: chatId } : {};
            const processed = process(text, ctx);

            if (processed !== text) {
                // Use the native setter so React detects the change
                nativeTextareaSetter.call(textarea, processed);
                // Fire input event so React updates its internal state
                textarea.dispatchEvent(new Event('input', { bubbles: true }));

                console.log(
                    '%c[QoL Macros]%c Replaced macros in textarea before send',
                    'color: #a78bfa; font-weight: bold',
                    'color: inherit'
                );
                console.groupCollapsed('[QoL Macros] Macro replacement details');
                console.log('Before:', text);
                console.log('After: ', processed);
                console.groupEnd();
                return true;
            }
            return false;
        }

        // ── Enter key (capture phase — fires before site's handler) ──
        document.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey) {
                const textarea = getTextarea();
                if (textarea && e.target === textarea) {
                    processMacrosInTextarea();
                }
            }
        }, true); // capture phase

        // ── Send button click (capture phase) ──
        document.addEventListener('click', (e) => {
            const sendBtn = e.target.closest(
                'button[aria-label="Send"], button[class*="_sendButton_"]'
            );
            if (sendBtn) {
                processMacrosInTextarea();
            }
        }, true); // capture phase

        console.log(
            '%c[QoL Macros]%c Textarea interceptor active — macros will be resolved in chat input before sending',
            'color: #a78bfa; font-weight: bold',
            'color: inherit'
        );
    }

    // ================================================================
    // === XHR/FETCH INTERCEPTOR (bot messages only) ===
    // ================================================================

    /**
     * For bot messages: intercept XHR/fetch POSTs to the messages endpoint
     * and replace macros in the message body when is_bot is true.
     *
     * Bot messages don't come from a textarea — they arrive from the AI
     * response and get saved via XHR, so network interception is the
     * only way to process them.
     */
    function initBotMessageInterceptor() {
        const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

        // Match: /hampter/chats/<digits>/messages
        const MESSAGES_URL_RE = /\/hampter\/chats\/\d+\/messages(?:\?|$)/;

        // Match: /generateAlpha
        const GENERATE_ALPHA_URL_RE = /\/generateAlpha(?:\?|$)/;

        /**
         * Attempt macro replacement on a JSON body string (bot messages only).
         * Returns the (possibly modified) body string.
         */
        function replaceMacrosInBotBody(bodyStr) {
            if (typeof bodyStr !== 'string' || !MACRO_DETECT_RE.test(bodyStr)) return bodyStr;
            try {
                const parsed = JSON.parse(bodyStr);
                // Only process bot messages
                if (
                    parsed &&
                    parsed.is_bot === true &&
                    typeof parsed.message === 'string' &&
                    typeof parsed.chat_id !== 'undefined' &&
                    MACRO_DETECT_RE.test(parsed.message)
                ) {
                    const original = parsed.message;
                    const ctx = { seed: String(parsed.chat_id) };
                    parsed.message = process(original, ctx);

                    if (parsed.message !== original) {
                        console.log(
                            '%c[QoL Macros]%c Replaced macros in bot message',
                            'color: #a78bfa; font-weight: bold',
                            'color: inherit'
                        );
                        console.groupCollapsed('[QoL Macros] Bot macro replacement details');
                        console.log('Before:', original);
                        console.log('After: ', parsed.message);
                        console.groupEnd();
                        return JSON.stringify(parsed);
                    }
                }
            } catch (e) {
                console.error('[QoL Macros] Bot interceptor error:', e);
            }
            return bodyStr;
        }

        /**
         * Attempt macro replacement on a generateAlpha JSON body string.
         * Processes userConfig.llm_prompt and userConfig.open_ai_jailbreak_prompt.
         * Returns the (possibly modified) body string.
         */
        function replaceMacrosInPrompts(bodyStr) {
            if (typeof bodyStr !== 'string' || !MACRO_DETECT_RE.test(bodyStr)) return bodyStr;
            try {
                const parsed = JSON.parse(bodyStr);
                if (!parsed || !parsed.userConfig) return bodyStr;

                const cfg = parsed.userConfig;
                const chatId = parsed.chat && parsed.chat.id;
                const ctx = chatId ? { seed: String(chatId) } : {};
                let changed = false;

                if (typeof cfg.llm_prompt === 'string' && MACRO_DETECT_RE.test(cfg.llm_prompt)) {
                    const original = cfg.llm_prompt;
                    cfg.llm_prompt = process(original, ctx);
                    if (cfg.llm_prompt !== original) {
                        changed = true;
                        console.log(
                            '%c[QoL Macros]%c Replaced macros in llm_prompt',
                            'color: #a78bfa; font-weight: bold',
                            'color: inherit'
                        );
                        console.groupCollapsed('[QoL Macros] llm_prompt replacement details');
                        console.log('Before:', original);
                        console.log('After: ', cfg.llm_prompt);
                        console.groupEnd();
                    }
                }

                if (typeof cfg.open_ai_jailbreak_prompt === 'string' && MACRO_DETECT_RE.test(cfg.open_ai_jailbreak_prompt)) {
                    const original = cfg.open_ai_jailbreak_prompt;
                    cfg.open_ai_jailbreak_prompt = process(original, ctx);
                    if (cfg.open_ai_jailbreak_prompt !== original) {
                        changed = true;
                        console.log(
                            '%c[QoL Macros]%c Replaced macros in open_ai_jailbreak_prompt',
                            'color: #a78bfa; font-weight: bold',
                            'color: inherit'
                        );
                        console.groupCollapsed('[QoL Macros] open_ai_jailbreak_prompt replacement details');
                        console.log('Before:', original);
                        console.log('After: ', cfg.open_ai_jailbreak_prompt);
                        console.groupEnd();
                    }
                }

                if (changed) return JSON.stringify(parsed);
            } catch (e) {
                console.error('[QoL Macros] generateAlpha interceptor error:', e);
            }
            return bodyStr;
        }

        // ── XHR interceptor (primary — JanitorAI uses XHR for messages) ──

        const XHR = target.XMLHttpRequest;
        if (XHR) {
            const origOpen = XHR.prototype.open;
            const origSend = XHR.prototype.send;

            XHR.prototype.open = function (method, url, ...rest) {
                this._qolMethod = method;
                this._qolUrl = url;
                return origOpen.call(this, method, url, ...rest);
            };

            XHR.prototype.send = function (body) {
                if (
                    this._qolMethod &&
                    this._qolMethod.toUpperCase() === 'POST' &&
                    body
                ) {
                    if (MESSAGES_URL_RE.test(this._qolUrl)) {
                        body = replaceMacrosInBotBody(body);
                    } else if (GENERATE_ALPHA_URL_RE.test(this._qolUrl)) {
                        body = replaceMacrosInPrompts(body);
                    }
                }
                return origSend.call(this, body);
            };
        }

        // ── Fetch interceptor (fallback) ──

        const originalFetch = target.fetch;
        if (originalFetch) {
            target.fetch = async function (...args) {
                let [resource, config] = args;
                const url = typeof resource === 'string' ? resource : (resource instanceof Request ? resource.url : '');
                const method = (config && config.method) || (resource instanceof Request ? resource.method : 'GET');

                if (method.toUpperCase() === 'POST' && config && config.body) {
                    if (MESSAGES_URL_RE.test(url)) {
                        config = Object.assign({}, config);
                        config.body = replaceMacrosInBotBody(config.body);
                    } else if (GENERATE_ALPHA_URL_RE.test(url)) {
                        config = Object.assign({}, config);
                        config.body = replaceMacrosInPrompts(config.body);
                    }
                }

                return originalFetch.apply(this, [resource, config]);
            };
        }

        console.log(
            '%c[QoL Macros]%c Bot message + generateAlpha interceptor active (XHR + fetch)',
            'color: #a78bfa; font-weight: bold',
            'color: inherit'
        );
    }

    // ================================================================
    // === BOOTSTRAP ===
    // ================================================================

    // Expose globally
    const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
    win.QoLMacros = API;

    // Detect Message Logger for Tier 2 macros
    detectMessageLogger();

    // Start intercepting chat messages
    initTextareaInterceptor();
    initBotMessageInterceptor();

    // Fire a ready event for scripts waiting on the library
    const readyEvent = new CustomEvent('qol-macros:ready', { detail: API });
    window.dispatchEvent(readyEvent);

    console.log(
        '%c[QoL Macros]%c Library v' + API.version + ' loaded — %c' + API.list().length + ' macros%c available. Try QoLMacros.demo()',
        'color: #a78bfa; font-weight: bold',
        'color: inherit',
        'color: #34d399; font-weight: bold',
        'color: inherit'
    );

})();