/* * sux background page — settings mirror, classification queue, Jev HTTP client, * verdict cache, and messaging hub (Ports to bsky.app content scripts, messages * from the sidebar). Runs as a Firefox MV3 non-persistent background page. */ /* global SuxShared, browser */ (function () { "use strict"; var SH = globalThis.SuxShared; var API_URL = "https://api.typesafe.ai/v1/systemone"; var CONCURRENCY = 2; var MAX_ATTEMPTS = 5; var CACHE_CAP = 2000; var VERDICT_CAP = 10000; var REQUEST_TIMEOUT_MS = 30000; var settings = SH.defaults(); var cache = new Map(); // cacheKey -> verdict (persisted) var verdictsById = new Map(); // post id -> verdict (session only) var ports = new Set(); var queue = []; // pending jobs: {key, text, alt, ids: []} var running = 0; var portStats = new Map(); // port -> last reported content-script diagnostics var pumpScheduled = false; var cacheWriteTimer = null; var settingsWriteTimer = null; var stats = { judged: 0, // verdicts served (API-computed + cache hits) hidden: 0, // of those, "hide" failed: 0, // API attempts that exhausted retries (failed open) lastError: "", lastErrorAt: 0, }; function recordError(context, err) { stats.failed++; stats.lastError = (context ? context + ": " : "") + String((err && err.message) || err).slice(0, 500); stats.lastErrorAt = Date.now(); } function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } /* ---------------- Jev API ---------------- */ async function jevRequest(apiKey, payload) { var delay = 2000; for (var attempt = 1; ; attempt++) { var res = await fetch(API_URL, { method: "POST", headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) : undefined, }); if (res.status === 429 || res.status === 529) { if (attempt >= MAX_ATTEMPTS) { throw new Error("Jev API overloaded (HTTP " + res.status + "), gave up after " + MAX_ATTEMPTS + " attempts"); } var retryAfter = res.headers.get("Retry-After"); var wait = retryAfter ? Math.min((parseInt(retryAfter, 10) || 0) * 1000, 60000) : delay; if (!wait || wait < 0) wait = delay; await sleep(wait); delay = Math.min(delay * 2, 30000); continue; } if (!res.ok) { var detail = ""; try { detail = (await res.text()).slice(0, 200); } catch (e) { /* ignore */ } throw new Error("Jev API HTTP " + res.status + (detail ? ": " + detail : "")); } return res.json(); } } async function classify(job) { var verdict = "show"; var details = null; try { // Parse with the settings the request was built with — the live settings // may have changed (rule toggled/words edited) while the job was queued. var st = job.settings || settings; var payload = SH.buildRequest(st, { text: job.text, alt: job.alt }); var res = await jevRequest(settings.apiKey, payload); var v = SH.decideFromAnswers(st, (res && res.answers) || null); if (v === null) { throw new Error( "Unexpected Jev response shape: " + JSON.stringify(res && res.answers).slice(0, 400) ); } verdict = v; details = SH.answerDetails(st, (res && res.answers) || null); } catch (err) { recordError("classify", err); verdict = "show"; // fail open: a broken filter must not brick the feed } stats.judged++; cache.set( job.key, details && Object.keys(details).length ? { v: verdict, d: details } : verdict ); trimCache(); scheduleCacheWrite(); for (var i = 0; i < job.ids.length; i++) { var id = job.ids[i]; verdictsById.set(id, { key: job.key, verdict: verdict }); if (verdictsById.size > VERDICT_CAP) { verdictsById.delete(verdictsById.keys().next().value); } } broadcast({ type: "verdicts", items: job.ids.map(function (id) { var item = { id: id, verdict: verdict }; if (details && Object.keys(details).length) item.d = details; return item; }), }); } /* ---------------- queue ---------------- */ function schedulePump() { if (pumpScheduled) return; pumpScheduled = true; setTimeout(function () { pumpScheduled = false; pump(); }, 0); } function pump() { if (!SH.isActive(settings)) { queue.length = 0; // filtering off → drop pending work return; } if (!settings.apiKey) return; // resolvePost fails open before enqueueing while (running < CONCURRENCY && queue.length) { var job = queue.shift(); running++; classify(job) .catch(function (err) { recordError("classify", err); }) .then(function () { running--; schedulePump(); }); } } // Returns an immediate verdict object, or null when the post is (now) pending. // In-memory verdicts are tagged with the rule-config key they belong to, so // flipping a rule or a direction never serves a stale verdict. function resolvePost(p) { var key = SH.isActive(settings) && SH.isJudgeable(p) && settings.apiKey ? SH.cacheKey(settings, p.text, p.alt) : null; var prev = verdictsById.get(p.id); if (prev && prev.key === key) { return { id: p.id, verdict: prev.verdict }; } if (!SH.isJudgeable(p)) { var open = { id: p.id, verdict: "show" }; // nothing to judge → fail open verdictsById.set(p.id, { key: key, verdict: open.verdict }); return open; } if (!SH.isActive(settings)) { return { id: p.id, verdict: "show" }; // deliberately not cached } if (!settings.apiKey) { stats.lastError = "Missing TypeSafe API key — add it in the sux sidebar."; stats.lastErrorAt = Date.now(); var v = { id: p.id, verdict: "show" }; verdictsById.set(p.id, { key: key, verdict: v.verdict }); return v; } if (cache.has(key)) { var entry = cache.get(key); var verdict = typeof entry === "string" ? entry : entry.v; // tolerate legacy string entries var d = typeof entry === "string" ? null : entry.d; verdictsById.set(p.id, { key: key, verdict: verdict }); stats.judged++; var r = { id: p.id, verdict: verdict }; if (d) r.d = d; return r; } var existing = null; for (var i = 0; i < queue.length; i++) { if (queue[i].key === key) { existing = queue[i]; break; } } if (existing) { if (existing.ids.indexOf(p.id) === -1) existing.ids.push(p.id); } else { queue.push({ key: key, text: p.text || "", alt: p.alt || "", ids: [p.id], settings: settings, // snapshot for request build + response parse }); } schedulePump(); return null; // pending } /* ---------------- persistence ---------------- */ function trimCache() { while (cache.size > CACHE_CAP) { cache.delete(cache.keys().next().value); } } function scheduleCacheWrite() { if (cacheWriteTimer) return; cacheWriteTimer = setTimeout(function () { cacheWriteTimer = null; var obj = {}; cache.forEach(function (v, k) { obj[k] = v; }); browser.storage.local.set({ cache: obj }).catch(function () { /* storage may be unavailable */ }); }, 2000); } function scheduleSettingsWrite() { if (settingsWriteTimer) return; settingsWriteTimer = setTimeout(function () { settingsWriteTimer = null; browser.storage.local.set({ settings: settings }).catch(function () { /* ignore */ }); }, 300); } async function loadState() { try { var stored = await browser.storage.local.get(["settings", "cache"]); if (stored.settings) settings = SH.normalizeSettings(stored.settings); if (stored.cache) { Object.keys(stored.cache).forEach(function (k) { cache.set(k, stored.cache[k]); }); } } catch (err) { recordError("load", err); } } /* ---------------- messaging ---------------- */ function broadcast(msg) { ports.forEach(function (port) { try { port.postMessage(msg); } catch (e) { ports.delete(port); } }); } browser.runtime.onConnect.addListener(function (port) { if (port.name !== "sux") return; ports.add(port); port.postMessage({ type: "settings", settings: settings, active: SH.isActive(settings) }); port.onMessage.addListener(function (msg) { if (!msg || typeof msg !== "object") return; if (msg.type === "contentStats") { portStats.set(port, msg.stats || {}); return; } if (msg.type !== "getVerdicts" || !Array.isArray(msg.posts)) return; var items = []; for (var i = 0; i < msg.posts.length; i++) { var r = resolvePost(msg.posts[i]); if (r) items.push(r); } if (items.length) { try { port.postMessage({ type: "verdicts", items: items }); } catch (e) { /* ignore */ } } }); port.onDisconnect.addListener(function () { ports.delete(port); portStats.delete(port); }); }); browser.runtime.onMessage.addListener(function (msg) { if (!msg || typeof msg !== "object") return undefined; switch (msg.type) { case "getSettings": return Promise.resolve({ settings: settings }); case "saveSettings": settings = SH.normalizeSettings(msg.settings); scheduleSettingsWrite(); broadcast({ type: "settings", settings: settings, active: SH.isActive(settings) }); if (!SH.isActive(settings)) queue.length = 0; return Promise.resolve({ ok: true, active: SH.isActive(settings) }); case "getStats": { var liveHidden = 0; var agg = { seen: 0, domExtracted: 0, items: 0, hidden: 0, unmatchedAnchors: 0, scans: 0 }; var activeSeen = false; portStats.forEach(function (s) { liveHidden += s.hidden || 0; Object.keys(agg).forEach(function (k) { agg[k] += s[k] || 0; }); if (s.settingsActive) activeSeen = true; }); agg.settingsActive = activeSeen; return Promise.resolve({ stats: { judged: stats.judged, hidden: liveHidden, // currently hidden, across tabs — 0 when filtering is off failed: stats.failed, lastError: stats.lastError, lastErrorAt: stats.lastErrorAt, waiting: queue.length + running, }, active: SH.isActive(settings), content: agg, }); } case "testKey": { var apiKey = typeof msg.apiKey === "string" ? msg.apiKey : ""; if (!apiKey) return Promise.resolve({ ok: false, error: "Enter an API key first." }); var probe = { state: { post: { text: "The kittens are playing in the garden again.", alt: "" } }, model: "jev-latest", questions: { ping: { type: "noul", instructions: "Does the `post` mention an animal?", }, }, }; return jevRequest(apiKey, probe).then( function (res) { var ok = !!(res && res.answers && res.answers.ping && typeof res.answers.ping.noul === "number"); return ok ? { ok: true, model: res.model || "jev-latest" } : { ok: false, error: "Response shape was not what sux expects." }; }, function (err) { return { ok: false, error: String((err && err.message) || err).slice(0, 300) }; } ); } default: return undefined; } }); loadState().then(function () { broadcast({ type: "settings", settings: settings, active: SH.isActive(settings) }); }); })();