// Pure URL construction. No browser APIs, so node:test imports this directly. // // Every ChatGPT parameter below is undocumented by OpenAI and has broken before. // This module's contract is only that it emits a well-formed URL; whether // ChatGPT honours model / temporary-chat / hints is out of our control, which is // why each is independently omittable and every label is hint-strength. // // The root path is mandatory: chatgpt.com/search began returning 404 in // mid-2025, and the root form is what OpenAI's own extension uses. const BASE = "https://chatgpt.com/"; export const MAX_URL_CHARS = 8000; // Fraction of the kept text within which a whitespace cut is preferred. const WHITESPACE_WINDOW = 0.9; function applyTemplate(template, query) { if (query === "") { return ""; } const tpl = typeof template === "string" ? template : ""; if (tpl.includes("{query}")) { return tpl.replaceAll("{query}", query); } const trimmed = tpl.trim(); return trimmed === "" ? query : `${trimmed} ${query}`; } function addOptionalParams(url, settings) { const model = typeof settings.model === "string" ? settings.model.trim() : ""; if (model !== "") { url.searchParams.set("model", model); } if (settings.temporaryChat) { url.searchParams.set("temporary-chat", "true"); } if (settings.webSearch) { url.searchParams.set("hints", "search"); } return url; } function lastWhitespaceIndex(text) { for (let i = text.length - 1; i >= 0; i -= 1) { if (/\s/.test(text[i])) { return i; } } return -1; } // Returns the longest prefix of `prompt` whose finished URL fits the budget. // Slicing happens by CODE POINT on the decoded string, so a surrogate pair can // never be split and a percent-escape can never be severed (encoding happens // afterwards, via URLSearchParams). function fitToBudget(prompt, settings) { const codePoints = Array.from(prompt); const urlLengthFor = (count) => { const url = new URL(BASE); url.searchParams.set("q", codePoints.slice(0, count).join("")); addOptionalParams(url, settings); return url.href.length; }; if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) { return prompt; } let low = 0; let high = codePoints.length; while (low < high) { const mid = Math.floor((low + high + 1) / 2); if (urlLengthFor(mid) <= MAX_URL_CHARS) { low = mid; } else { high = mid - 1; } } const kept = codePoints.slice(0, low).join(""); const boundary = Math.floor(kept.length * WHITESPACE_WINDOW); const cut = lastWhitespaceIndex(kept); return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept; } export function buildChatGptUrl(settings, rawQuery) { const query = String(rawQuery ?? "").trim(); const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings); // q first, matching the most-cited working examples. Total serialized length // is order-independent, so measuring without q above is sound. const url = new URL(BASE); if (prompt !== "") { url.searchParams.set("q", prompt); } addOptionalParams(url, settings); return url.href; }