Something went wrong. Try again.
this post sux
Something went wrong. Try again.
96 kB · 2172 lines
JavaScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173// ==UserScript==// @name sux — Jev feed filter// @namespace sux// @version 0.2.0// @description Filters Bluesky feeds with Jev (TypeSafe System One) judgments: keep posts that lean toward one word over another, or that are (not) mostly some word.// @match *://bsky.app/*// @run-at document-start// @connect api.typesafe.ai// @grant GM_xmlhttpRequest// @grant GM_getValue// @grant GM_setValue// @grant GM_addValueChangeListener// @grant GM_registerMenuCommand// @grant unsafeWindow// @noframes// ==/UserScript==
// Generated by userscript/build.mjs — edit userscript/src/ and ../shared.js, not this file.(function () { "use strict";
var SuxShared = (function (globalThis) { /* * sux shared logic — pure functions, no extension APIs. * Loaded as a classic script in the background page, content script, and sidebar, * and require()-able from Node for the unit tests. */ (function (global) { "use strict";
var TEXT_CAP = 2000; var EX_CAP = 10; // newest example/counter posts sent to Jev per rule var EX_TEXT_CAP = 500; var WORD_CAP = 80;
// Build-time switch for the local-model feature (SUX-11). Off in current // builds: the in-browser runtime is delivered separately, and until it // registers an embedder the Local UI is a dead end. With LOCAL_MODEL off, // settings.mode collapses to "both" — which, without a runtime, behaves // exactly like Jev-only — and the sidebar hides the chooser. Flip to true // in a build that ships the runtime. var LOCAL_MODEL = false;
function cleanWord(w) { if (typeof w !== "string") return ""; return w.trim().toLowerCase().slice(0, WORD_CAP); }
function normalizeClauseA(c) { c = c || {}; return { enabled: !!c.enabled, word1: cleanWord(c.word1), word2: cleanWord(c.word2), trained: normalizeTrained(c.trained), }; }
function normalizeClauseB(c) { c = c || {}; return { enabled: !!c.enabled, word: cleanWord(c.word), above: c.above !== false, trained: normalizeTrained(c.trained), }; }
// SUX-9: word rules accumulate a local separator from Jev's confident // answers — hide-side posts (the rule would hide them) and keep-side posts // (it would show them). Once both sides exist, the separator scores new // posts locally and Jev calls fall toward zero. function normalizeTrained(t) { t = t || {}; var clean = function (list) { return (Array.isArray(list) ? list : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }); }; return { hide: clean(t.hide), keep: clean(t.keep) }; }
function normalizeExample(x) { x = x || {}; var ex = { text: typeof x.text === "string" ? x.text.slice(0, TEXT_CAP) : "", alt: typeof x.alt === "string" ? x.alt.slice(0, TEXT_CAP) : "", }; if (Array.isArray(x.emb) && x.emb.every(function (v) { return typeof v === "number" && isFinite(v); })) { ex.emb = x.emb.slice(); } return ex; }
// SUX-4 rule type E: "hide posts like these". Built from a handful of // example posts the user doesn't want to see; scored locally against the // feed average (SUX-3). `cutoff` is what SUX-5 sets automatically; until // then 0 (score > 0 hides) matches the bench's flag-if-positive rule. // `counters` (SUX-6) are posts the user peeked and said "not this" — they // sharpen the "not this" side of scoring; they never replace the feed side. function normalizeClauseE(c) { c = c || {}; return { enabled: !!c.enabled, name: typeof c.name === "string" ? c.name.trim().slice(0, WORD_CAP) : "", examples: (Array.isArray(c.examples) ? c.examples : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }), counters: (Array.isArray(c.counters) ? c.counters : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }), cutoff: typeof c.cutoff === "number" && isFinite(c.cutoff) ? c.cutoff : 0, strictness: typeof c.strictness === "number" && c.strictness >= 0 && c.strictness <= 1 ? c.strictness : 0.5, }; }
// Canonical settings shape: arbitrary clauses per rule type. Settings stored // by older builds had single ruleA/ruleB objects; those migrate to one-element // arrays so saved filters keep working. function normalizeSettings(s) { s = s || {}; var aSrc = Array.isArray(s.rulesA) ? s.rulesA : (s.ruleA ? [s.ruleA] : []); var bSrc = Array.isArray(s.rulesB) ? s.rulesB : (s.ruleB ? [s.ruleB] : []); return { enabled: !!s.enabled, apiKey: typeof s.apiKey === "string" ? s.apiKey : "", combine: s.combine === "or" ? "or" : "and", peek: !!s.peek, // SUX-6: hidden posts collapse to placeholders instead of vanishing // SUX-11: which backends may run. "local" = never call Jev (no network); // "jev" = Jev judges everything; "both" = the SUX-7 hybrid (local first, // Jev for the unsure). Fail-open stays: a backend that can't run shows // posts. Gated by the LOCAL_MODEL build switch — with the switch off, // mode collapses to "both" and the sidebar hides the chooser. mode: LOCAL_MODEL && (s.mode === "local" || s.mode === "jev") ? s.mode : "both", rulesA: aSrc.map(normalizeClauseA), rulesB: bSrc.map(normalizeClauseB), rulesE: (Array.isArray(s.rulesE) ? s.rulesE : []).map(normalizeClauseE), }; }
function modeAllowsJev(mode) { return mode !== "local"; } function modeAllowsLocal(mode) { return mode !== "jev"; }
// SUX-11: the runtime reports download/init progress through here; the // background mirrors it into getStats and the sidebar shows the bar. var modelProgress = { pct: 0, status: "idle", at: 0 }; function reportModelProgress(pct, status) { modelProgress = { pct: typeof pct === "number" && pct >= 0 && pct <= 100 ? pct : 0, status: typeof status === "string" ? status.slice(0, 80) : "", at: Date.now(), }; } function getModelProgress() { return modelProgress; }
function defaults() { return normalizeSettings(null); }
// A clause needs two distinct usable words. "neither" is reserved as the // Choice's third option, so a word colliding with it would silently overwrite // that option. function clauseAValid(c) { return !!c.word1 && !!c.word2 && c.word1 !== c.word2 && c.word1 !== "neither" && c.word2 !== "neither"; }
function clauseBValid(c) { return !!c.word; }
// An example rule needs at least one example to compare against. function clauseEValid(c) { return c.examples.length > 0; }
// True when the filter should actually run: master on AND at least one usable // enabled clause. function isActive(st) { return st.enabled && (st.rulesA.some(function (c) { return c.enabled && clauseAValid(c); }) || st.rulesB.some(function (c) { return c.enabled && clauseBValid(c); }) || st.rulesE.some(function (c) { return c.enabled && clauseEValid(c); })); }
// FNV-1a — small, fast, dependency-free; collisions only cause a shared verdict, // which identical config+content would produce anyway with overwhelming likelihood. function hash(str) { var h = 0x811c9dc5; for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16); }
// Cache key = clause config + post content. Changing any word or the combinator // invalidates old verdicts; reposts with identical text share one key (and one // API call) because the post id is deliberately not part of the key. Disabled // or invalid clauses keep their slot ("") so array indexes stay aligned with // the question/detail keys. Example rules hash their examples plus the model // id — swap the model or the examples and every verdict is recomputed. function cacheKey(st, text, alt, modelId) { // Trained data (SUX-9) changes local scores, so it joins the key: a rule // that has learned from Jev must not serve verdicts cached before it learned. var trainedHash = function (c) { var t = c.trained || { hide: [], keep: [] }; return hash(t.hide.length + ":" + t.keep.length); }; var cfg = st.rulesA.map(function (c, i) { return c.enabled && clauseAValid(c) ? "A" + i + ":" + c.word1 + "|" + c.word2 + "|" + trainedHash(c) : ""; }); st.rulesB.forEach(function (c, i) { cfg.push(c.enabled && clauseBValid(c) ? "B" + i + ":" + c.word + "|" + (c.above ? "above" : "below") + "|" + trainedHash(c) : ""); }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c)) { cfg.push(""); return; } var ex = c.examples.map(function (x) { return x.text + "" + x.alt; }).join(""); var cx = (c.counters || []).map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); cfg.push("E" + i + ":" + hash(ex) + "|" + hash(cx) + "|" + c.cutoff + "|" + c.strictness + "|" + (modelId || "")); }); cfg.push(st.combine); return hash(cfg.join(";")) + ":" + hash((text || "") + "\n" + (alt || "")); }
// Per-clause score cache keys: one entry per (clause config, post content), // so editing or toggling one clause leaves every other clause's cached // scores usable. The key holds only what changes that clause's score — // rule B's direction and the combinator are applied at decide time, so // flipping them costs no calls. Index-free, so reordering clauses is free // too. Returns [{id: "A0", score: "p0", key}] for enabled valid clauses. function clauseScoreKeys(st, text, alt, modelId) { var post = hash((text || "") + "\n" + (alt || "")); var trained = function (c) { var t = c.trained || { hide: [], keep: [] }; return t.hide.length + ":" + t.keep.length; }; var out = []; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c)) return; out.push({ id: "A" + i, score: "p" + i, key: "A" + hash(c.word1 + "|" + c.word2 + "|" + trained(c) + "|" + (modelId || "")) + ":" + post }); }); st.rulesB.forEach(function (c, i) { if (!c.enabled || !clauseBValid(c)) return; out.push({ id: "B" + i, score: "n" + i, key: "B" + hash(c.word + "|" + trained(c) + "|" + (modelId || "")) + ":" + post }); }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c)) return; var ex = c.examples.map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); var cx = (c.counters || []).map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); out.push({ id: "E" + i, score: "e" + i, key: "E" + hash(ex + "|" + cx + "|" + c.cutoff + "|" + c.strictness + "|" + (modelId || "")) + ":" + post }); }); return out; }
function isJudgeable(post) { return !!((post.text && post.text.trim()) || (post.alt && post.alt.trim())); }
// Jev questions, one per enabled valid clause, keyed compare<i>/single<i>/ // example<i> so clauses of the same type stay distinguishable. Wording // follows the jev skill: one property per question, named state path in // backticks, contrastive criteria, no policy in the question (thresholds // live in decideFromScores). Example-rule criteria describe the rule's // intent; the example posts themselves are not sent. // // `only` (SUX-7) restricts to the listed clause ids ("A0", "B1", "E2") so // an escalation round-trips just the unsure clauses; omitted → all. // // `ref` names the post's state path ("post" by default); batched requests // put several posts in one state as post0, post1, …. function buildQuestions(st, only, ref) { var want = only || null; var at = "`" + (ref || "post") + "`"; var wants = function (id) { return !want || want.indexOf(id) !== -1; }; var q = {}; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c) || !wants("A" + i)) return; var w1 = c.word1; var w2 = c.word2; var cmp = { type: "choice", instructions: "Which option best describes the main topic or overall vibe of the post in " + at + "? Judge the post's own words only.", }; cmp.criteria = {}; cmp.criteria[w1] = "The post is mainly about " + w1 + ", or mainly expresses " + w1 + "."; cmp.criteria[w2] = "The post is mainly about " + w2 + ", or mainly expresses " + w2 + "."; cmp.criteria.neither = "The post is about neither " + w1 + " nor " + w2 + ", is about both equally, or it is too unclear to tell."; q["compare" + i] = cmp; }); st.rulesB.forEach(function (c, i) { if (!c.enabled || !clauseBValid(c) || !wants("B" + i)) return; var w = c.word; q["single" + i] = { type: "noul", instructions: "Does the post in " + at + " read as \"" + w + "\"? Judge the post's own words only.", criteria: { true: "The post is mainly about " + w + " or clearly expresses " + w + ".", false: "The post is not about " + w + ", or only mentions it in passing.", }, }; }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c) || !wants("E" + i)) return; var name = c.name || "the hidden examples"; q["example" + i] = { type: "noul", instructions: "The user has a rule (\"" + name + "\") built from example posts they don't want to see: `rule" + i + ".examples`. Posts in `rule" + i + ".not_like` were wrongly matched before — they are NOT that kind of post. Does the post in " + at + " match the kind of post the examples represent? Judge the post's own words only.", criteria: { true: "The post is the same kind of post as the rule's examples — someone who hid those would want this hidden too.", false: "The post is a different kind of post, or it only superficially resembles the examples.", }, }; }); return q; }
function buildRequest(st, post, only) { var req = { state: { post: { text: (post.text || "").slice(0, TEXT_CAP), alt: (post.alt || "").slice(0, TEXT_CAP), }, }, model: "jev-latest", questions: buildQuestions(st, only), }; // Example rules: Jev needs the examples themselves, not just the name. // Capped so a long-lived rule can't balloon every request. Object.keys(req.questions).forEach(function (k) { var m = /^example(\d+)$/.exec(k); if (!m) return; var c = st.rulesE[+m[1]]; req.state["rule" + m[1]] = { examples: c.examples.slice(-EX_CAP).map(exampleForJev), not_like: (c.counters || []).slice(-EX_CAP).map(exampleForJev), }; }); return req; }
// Several posts in one call (one state, one question set per post). Jev // evaluates every question independently against the shared state, so a // batch costs one round-trip instead of one per post. items: [{post, only}]. // Questions are keyed j<k>_<question>; splitBatchAnswers undoes that. function buildBatchRequest(st, items) { var req = { state: {}, model: "jev-latest", questions: {} }; items.forEach(function (it, k) { req.state["post" + k] = { text: (it.post.text || "").slice(0, TEXT_CAP), alt: (it.post.alt || "").slice(0, TEXT_CAP), }; var q = buildQuestions(st, it.only, "post" + k); Object.keys(q).forEach(function (name) { var m = /^example(\d+)$/.exec(name); if (m && !req.state["rule" + m[1]]) { var c = st.rulesE[+m[1]]; req.state["rule" + m[1]] = { examples: c.examples.slice(-EX_CAP).map(exampleForJev), not_like: (c.counters || []).slice(-EX_CAP).map(exampleForJev), }; } req.questions["j" + k + "_" + name] = q[name]; }); }); return req; }
// Per-post answer maps (unprefixed, as jevToScores expects) from a batch. function splitBatchAnswers(answers, n) { var out = []; for (var k = 0; k < n; k++) out.push({}); Object.keys(answers || {}).forEach(function (name) { var m = /^j(\d+)_(.+)$/.exec(name); if (m && +m[1] < n) out[+m[1]][m[2]] = answers[name]; }); return out; }
function exampleForJev(x) { var t = (x.text || "").slice(0, EX_TEXT_CAP); return x.alt ? t + " [image: " + x.alt.slice(0, EX_TEXT_CAP) + "]" : t; }
// Reduce Jev's answers to a plain per-rule score map: p<i> = P(word1) for // clause A<i>, n<i> = Noul for clause B<i> (example-based rules will add // e<i>). This is answerDetails without the rounding — the seam every // backend (Jev, local embeddings, a mix) feeds into decideFromScores. // Clauses with no usable answer are omitted; decideFromScores treats a // missing score as "no verdict". function jevToScores(st, answers) { var scores = {}; for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var probs = answers && answers["compare" + i] && answers["compare" + i].probabilities; var p = probs && typeof probs[a.word1] === "number" ? probs[a.word1] : null; if (p !== null) scores["p" + i] = p; } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var single = answers && answers["single" + j]; var n = single && typeof single.noul === "number" ? single.noul : null; if (n !== null) scores["n" + j] = n; } // SUX-7: an escalated example rule comes back as example<k>.noul, a 0..1 // probability on Jev's scale. decideFromScores compares e<k> against the // clause's local cutoff (a cosine-gap scale), so map Jev's confidence onto // that scale: p=1 → a full ESC_MARGIN past the cutoff on the hide side, // p=0 → a full margin on the show side. The verdict is Jev's either way. for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var exAns = answers && answers["example" + k]; var jp = exAns && typeof exAns.noul === "number" ? exAns.noul : null; if (jp !== null) scores["e" + k] = escalatedToLocalScale(jp, effectiveCutoff(e)); } return scores; }
// Decide from a plain score map (any backend) — "show" | "hide" | null. // Null means an enabled clause had no score (transient/malformed backend // output) — callers fail open in that case. // // Score keys: p<i> = P(word1) for clause A<i>, n<i> = Noul for clause B<i>, // e<i> = similarity-minus-feed for clause E<i> (higher = more like the // examples = more hideable). An E clause hides at or above its effective // cutoff (SUX-5: auto leave-one-out value shifted by the strictness slider). function decideFromScores(st, scores) { var results = []; for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var p = scores && typeof scores["p" + i] === "number" ? scores["p" + i] : null; if (p === null) return null; results.push(p >= 0.5); // "at least 50% word1" } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var n = scores && typeof scores["n" + j] === "number" ? scores["n" + j] : null; if (n === null) return null; results.push(b.above ? n > 0.5 : n < 0.5); // strict: exactly 0.5 fails both ways } for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var s = scores && typeof scores["e" + k] === "number" ? scores["e" + k] : null; if (s === null) return null; results.push(s < effectiveCutoff(e)); // below the hide-line → this clause doesn't hide } if (results.length === 0) return null; var pass = st.combine === "or" ? results.some(function (x) { return x; }) : results.every(function (x) { return x; }); return pass ? "show" : "hide"; }
// Jev-shaped convenience wrapper — kept while callers of the raw answer // shape (the userscript) still exist. function decideFromAnswers(st, answers) { return decideFromScores(st, jevToScores(st, answers)); }
// Round a plain score map for storage/telemetry (cache details, sidebar). function roundScores(scores) { var d = {}; Object.keys(scores || {}).forEach(function (k) { if (typeof scores[k] === "number" && isFinite(scores[k])) { d[k] = Math.round(scores[k] * 1000) / 1000; } }); return d; }
// Score details behind a verdict — telemetry for tuning and for explaining // near-threshold behavior. Rounded view of jevToScores: p<i> = P(word1) for // clause A<i>, n<i> = Noul for clause B<i>. function answerDetails(st, answers) { return roundScores(jevToScores(st, answers)); }
/* ---------------- model runtime interface ---------------- * * SUX-2: the in-browser model runtime implements this interface and hands * it to SuxShared.registerEmbedder. sux only ever touches these members, so * any model that can turn post text into vectors plugs in the same way: * * { * id: "embeddinggemma-300m-q4@<rev>", // identity — goes into cache keys * dims: 768, // vector width * ready(): Promise<void>, // download + init, reports progress * embed(texts: string[], kind: "post" | "query"): Promise<Float32Array[]> * } * * `kind` hides the model's prompt prefixes from sux (EmbeddingGemma uses * "task: classification | query: "); the runtime owns those strings. One * call embeds many texts. Laya does not fit this interface — the bench says * it isn't worth keeping anyway. */
var registeredEmbedder = null;
// Returns null when e satisfies the interface, else a short reason. Pure // shape check only; ready()/embed() behavior is the runtime's business. function checkEmbedder(e) { if (!e || typeof e !== "object") return "embedder must be an object"; if (typeof e.id !== "string" || !e.id) return "id must be a non-empty string"; if (typeof e.dims !== "number" || e.dims <= 0 || Math.floor(e.dims) !== e.dims) { return "dims must be a positive integer"; } if (typeof e.ready !== "function") return "ready() missing"; if (typeof e.embed !== "function") return "embed() missing"; return null; }
function registerEmbedder(e) { var problem = checkEmbedder(e); if (problem) throw new Error("bad embedder: " + problem); registeredEmbedder = e; }
function getEmbedder() { return registeredEmbedder; }
/* ---------------- vector math (pure; used by local scoring) ---------------- */
// Cosine similarity; 0 when either side is a zero vector (a missing // embedding must not produce NaN downstream). function cosine(a, b) { var dot = 0, na = 0, nb = 0; var n = Math.min(a ? a.length : 0, b ? b.length : 0); for (var i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } if (!na || !nb) return 0; return dot / (Math.sqrt(na) * Math.sqrt(nb)); }
// Component-wise mean of a non-empty array of equal-length vectors. function meanVector(vecs) { if (!vecs.length) return null; var out = new Array(vecs[0].length); for (var d = 0; d < out.length; d++) { var s = 0; for (var i = 0; i < vecs.length; i++) s += vecs[i][d]; out[d] = s / vecs.length; } return out; }
/* ---------------- SUX-4: example-rule scoring (pure) ---------------- */
// "Hide posts like these": how much more the post looks like the examples // than like the user's feed overall. Positive → closer to the examples than // to the feed average. Bench: AUROC 0.97 politics / 0.96 angry with 3–5 // examples and the pool as the "not this" side. // // SUX-6: counter-examples (posts the user peeked and rejected) sharpen the // "not this" side — they join the feed average in the contrast, they never // replace it. Near misses on top of the feed gave the best fuzzy-rule // results in the bench (self-promotion 0.83, funny 0.75); without the feed // average they're worse than nothing. function scoreExampleRule(postEmb, exampleEmbs, feedAvgEmb, counterEmbs) { var likeExamples = cosine(postEmb, meanVector(exampleEmbs)); var notThis = []; if (feedAvgEmb) notThis.push(feedAvgEmb); if (counterEmbs) { for (var i = 0; i < counterEmbs.length; i++) notThis.push(counterEmbs[i]); } var likeNotThis = notThis.length ? cosine(postEmb, meanVector(notThis)) : 0; return likeExamples - likeNotThis; }
/* ---------------- SUX-5: automatic cutoff + strictness ---------------- * * Natural cutoffs are too cautious: with 5 politics examples, flagged * posts are right 89% of the time but only 65% of politics posts get * flagged. So the cutoff comes from the examples themselves: score each * against the mean of the others (leave-one-out) and take the lowest — * every example just barely hides — then back off by MARGIN so near-miss * posts still hide. The strictness slider shifts the cutoff ±MARGIN around * that default (0.5 = the auto value). */
var CUTOFF_MARGIN = 0.05; // How far the strictness slider walks from the auto cutoff. The bench sets // this: the PR frontier for 5-example rules spans ≈ looMin−0.05 … looMin+0.10 // (P 0.37→0.84, R 0.98→0.68 on politics), so ±0.15 covers the useful range; // ±margin would spend the whole slider on a sliver of it. var STRICTNESS_SPAN = 0.15;
// Lowest leave-one-out score minus margin; null with fewer than 2 examples // (leave-one-out of one example is cos(x, x) = 1, a degenerate cutoff). function looCutoff(exampleEmbs, feedAvgEmb, margin) { if (!exampleEmbs || exampleEmbs.length < 2) return null; var m = typeof margin === "number" ? margin : CUTOFF_MARGIN; var lowest = Infinity; for (var i = 0; i < exampleEmbs.length; i++) { var others = []; for (var j = 0; j < exampleEmbs.length; j++) { if (j !== i) others.push(exampleEmbs[j]); } var s = scoreExampleRule(exampleEmbs[i], others, feedAvgEmb); if (s < lowest) lowest = s; } return lowest - m; }
// The cutoff decideFromScores actually uses: the stored auto-cutoff shifted // by the slider. Higher strictness → higher cutoff → fewer posts hidden. // strictness 0.5 = the auto value; 0/1 walk ±STRICTNESS_SPAN. function effectiveCutoff(c) { var base = typeof c.cutoff === "number" && isFinite(c.cutoff) ? c.cutoff : 0; var strictness = typeof c.strictness === "number" ? c.strictness : 0.5; return base + (strictness - 0.5) * 2 * STRICTNESS_SPAN; }
// SUX-6: which clause hid this post? The first enabled valid clause whose // score crosses its hide-line, E before B before A — example rules first // because they're the ones corrections can act on. Returns {type, index, // key, score, name} or null when nothing hides (peek placeholders only // render on hide, so null is unusual but safe). function hiddenByClause(st, scores) { for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var s = scores && scores["e" + k]; if (typeof s === "number" && s >= effectiveCutoff(e)) { return { type: "E", index: k, key: "e" + k, score: s, name: e.name || "hidden examples" }; } } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var n = scores && scores["n" + j]; if (typeof n === "number" && (b.above ? n <= 0.5 : n >= 0.5)) { return { type: "B", index: j, key: "n" + j, score: n, name: b.word }; } } for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var p = scores && scores["p" + i]; if (typeof p === "number" && p < 0.5) { return { type: "A", index: i, key: "p" + i, score: p, name: a.word1 + " vs " + a.word2 }; } } return null; }
/* ---------------- SUX-8: is this rule too fuzzy for the local model? ---------------- * * Tone and intent rules (funny, self-promotion) land ~0.15 AUROC below * topic rules no matter how many examples they get; the local model can't * see them. The heuristic is the mean leave-one-out score — how well the * examples recognize each other over the feed. Bench validation: medians * separate crisply (politics -0.027, angry +0.062 vs self-promotion * -0.053, funny -0.059, threshold -0.04) and match the AUROC ranking. * Mean pairwise similarity was tested and rejected (self-promotion * examples cluster tightly — they just don't separate from the feed). * Draw-to-draw tails overlap, so the badge is a warning, not a verdict. */
var FUZZY_LMEAN_THRESHOLD = -0.04;
// Mean leave-one-out score, or null with fewer than 2 examples. function ruleFuzziness(exampleEmbs, feedAvgEmb) { if (!exampleEmbs || exampleEmbs.length < 2) return null; var sum = 0; for (var i = 0; i < exampleEmbs.length; i++) { var others = []; for (var j = 0; j < exampleEmbs.length; j++) { if (j !== i) others.push(exampleEmbs[j]); } sum += scoreExampleRule(exampleEmbs[i], others, feedAvgEmb); } return sum / exampleEmbs.length; }
// true = warn: this rule needs Jev for good results. function needsJevForQuality(exampleEmbs, feedAvgEmb) { var f = ruleFuzziness(exampleEmbs, feedAvgEmb); return f === null ? false : f < FUZZY_LMEAN_THRESHOLD; }
/* ---------------- SUX-9: word rules trained from Jev's answers ---------------- * * The bench check (results_train_from_jev.md): a separator trained on * Jev's confident answers matches one trained on Claude's labels for * topic rules (politics 0.981 vs 0.981, angry 0.965 vs 0.964) and is ~89% * local after 50 Jev calls; funny lags (0.68 vs 0.73) — the SUX-8 fuzzy * class, which goes to Jev anyway. TRAIN_T sets how fast the trained * pseudo-probability saturates; 0.05 puts ~90% of scores outside the * SUX-7 band once ~30 posts are trained. */
var TRAIN_T = 0.05;
function trainedSidesReady(t) { return !!t && t.hide.length >= 1 && t.keep.length >= 1 && (t.hide.length + t.keep.length) >= 3; }
// Probability the rule hides the post, from the trained separator. function trainedHideProb(postEmb, trained, feedAvgEmb) { return 1 / (1 + Math.exp(-scoreExampleRule(postEmb, trained.hide, feedAvgEmb, trained.keep) / TRAIN_T)); }
// Which side of the separator a confident Jev answer lands on: "keep" (the // rule would show the post), "hide" (it would hide), or null (not confident // enough to train on). Direction-aware for rule B. function trainedSide(kind, clause, score) { if (typeof score !== "number") return null; if (kind === "A") { if (score >= 0.8) return "keep"; // confidently word1 → shown if (score <= 0.2) return "hide"; // confidently word2 → hidden return null; } if (score >= 0.8) return clause.above ? "keep" : "hide"; // confidently the word if (score <= 0.2) return clause.above ? "hide" : "keep"; // confidently not return null; }
// trainedHideProb back onto the clause's native probability scale: p<i> = // P(word1), n<i> = P(word). Hide prob is P(hidden); the native score is its // complement for A and for B-with-above (shown when the word matches). function trainedToNative(hideProb, kind, clause) { if (kind === "A") return 1 - hideProb; return clause.above ? 1 - hideProb : hideProb; }
/* ---------------- SUX-3: embedding cache keys, feed average ---------------- */
// Keyed by model id + post content, deliberately separate from cacheKey: // rule changes must not redo model work. Same FNV-1a as cacheKey; a shared // embedding on collision is harmless for the same reason. function embCacheKey(modelId, text, alt) { return hash(modelId + "\n" + (text || "") + "\n" + (alt || "")); }
// One EMA step: prev null → copy of next; otherwise out = (1-a)·prev + a·next. // Vectors are unnormalized embeddings, so the average keeps drifting toward // whatever the feed currently looks like; cosine scoring is scale-insensitive. function emaStep(prev, next, alpha) { if (!prev) return next.slice(); var out = new Array(next.length); for (var d = 0; d < next.length; d++) out[d] = (1 - alpha) * prev[d] + alpha * next[d]; return out; }
/* ---------------- SUX-10: zero-shot wording for word rules ---------------- * * Jev's criteria read "The post is mainly about angry or clearly expresses * angry" — ungrammatical for adjectives, and it drags the embedder too * (topic accuracy 0.40). These short natural descriptions are embedding-side * only; Jev keeps its own wording. Bench (results_wording*.md, q4): the * about/else pair embedded with kind "query" beats the old criteria text on * all five rules — politics 0.971 vs 0.945, angry 0.922 vs 0.916, * self-promotion 0.713 vs 0.599, funny 0.688 vs 0.602, topic 0.46 vs 0.40. */
function zeroShotDescriptions(word) { var w = String(word || "").trim(); return { pos: "A post about " + w + ".", neg: "A post about something else.", }; }
// Bench temperature: noul = sigmoid((cos_pos − cos_neg)/T), T = 0.02. Rule A // reuses it as P(word1) = sigmoid((cos(w1) − cos(w2))/T). var ZS_T = 0.02;
function zeroShotProb(cosFor, cosAgainst) { return 1 / (1 + Math.exp(-(cosFor - cosAgainst) / ZS_T)); }
/* ---------------- SUX-7: escalate unsure scores to Jev ---------------- * * A rule decides locally when its score sits at least ESC_MARGIN clear of * its boundary (0.5 for word rules, the effective cutoff for example * rules); inside the band the local model is guessing, so the clause goes * to Jev instead. Missing local score → Jev (that's the pre-escalation * behavior for word rules without a runtime). * * ESC_MARGIN is bench-set (bench/results_escalation.md): 0.2 spends the * escalation budget best — politics agrees with Jev on ~95% of posts at * ~13% escalated; 0.1 halves the Jev spend for ~2-4pp less agreement. The * ticket's "within 0.02 of Jev-only" guess is optimistic: zero-shot scores * are over-confident (T=0.02 saturates fast), so the band catches few * posts, and tone/intent rules (funny, self-promotion) stay ~0.15 AUROC * below Jev at any sane escalation rate — that's the SUX-8 fuzzy class. */
var ESC_MARGIN = 0.2;
function needsEscalation(score, boundary, margin) { var m = (typeof margin === "number" ? margin : ESC_MARGIN) - 1e-9; return Math.abs(score - boundary) < m; }
// Partition enabled valid clauses by backend. scores maps p<i>/n<i>/e<k> to // local scores; a clause with a score clear of its boundary is local, one // inside the band (or unscored) needs Jev. Returns {local, jev} of clause // ids ("A0", "B1", "E2" — array indexes into rulesA/rulesB/rulesE). function escalationPlan(st, scores, margin) { var plan = { local: [], jev: [] }; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c)) return; var p = scores && typeof scores["p" + i] === "number" ? scores["p" + i] : null; (p !== null && !needsEscalation(p, 0.5, margin) ? plan.local : plan.jev).push("A" + i); }); st.rulesB.forEach(function (c, j) { if (!c.enabled || !clauseBValid(c)) return; var n = scores && typeof scores["n" + j] === "number" ? scores["n" + j] : null; (n !== null && !needsEscalation(n, 0.5, margin) ? plan.local : plan.jev).push("B" + j); }); st.rulesE.forEach(function (c, k) { if (!c.enabled || !clauseEValid(c)) return; var s = scores && typeof scores["e" + k] === "number" ? scores["e" + k] : null; (s !== null && !needsEscalation(s, effectiveCutoff(c), margin) ? plan.local : plan.jev).push("E" + k); }); return plan; }
// Map an escalated Jev probability onto the local score scale so it // compares correctly against an example rule's cutoff: p=1 lands a full // margin on the hide side, p=0 on the show side, p=0.5 at the cutoff. function escalatedToLocalScale(p, cutoff, margin) { var m = typeof margin === "number" ? margin : ESC_MARGIN; return cutoff + (2 * p - 1) * m; }
// int8 storage (768 floats ≈ 3 KB → ≈ 0.8 KB per post at the 2,000-entry // cap): per-vector max-abs scaling, values snapped to [-127, 127]. SUX-12 // flags the loss if it ever matters; cosine barely notices it. function quantEmb(emb) { var max = 0; for (var i = 0; i < emb.length; i++) { var a = Math.abs(emb[i]); if (a > max) max = a; } if (!max) return { s: 1, q: emb.map(function () { return 0; }) }; var s = max / 127; return { s: s, q: emb.map(function (x) { return Math.round(x / s); }) }; }
function dequantEmb(qe) { var s = qe.s || 1; return qe.q.map(function (x) { return x * s; }); }
var SuxShared = { TEXT_CAP: TEXT_CAP, WORD_CAP: WORD_CAP, defaults: defaults, normalizeSettings: normalizeSettings, LOCAL_MODEL: LOCAL_MODEL, modeAllowsJev: modeAllowsJev, modeAllowsLocal: modeAllowsLocal, reportModelProgress: reportModelProgress, getModelProgress: getModelProgress, clauseAValid: clauseAValid, clauseBValid: clauseBValid, clauseEValid: clauseEValid, isActive: isActive, hash: hash, cacheKey: cacheKey, isJudgeable: isJudgeable, buildQuestions: buildQuestions, buildRequest: buildRequest, buildBatchRequest: buildBatchRequest, splitBatchAnswers: splitBatchAnswers, clauseScoreKeys: clauseScoreKeys, decideFromAnswers: decideFromAnswers, jevToScores: jevToScores, decideFromScores: decideFromScores, answerDetails: answerDetails, roundScores: roundScores, checkEmbedder: checkEmbedder, registerEmbedder: registerEmbedder, getEmbedder: getEmbedder, cosine: cosine, meanVector: meanVector, scoreExampleRule: scoreExampleRule, zeroShotDescriptions: zeroShotDescriptions, zeroShotProb: zeroShotProb, ZS_T: ZS_T, ESC_MARGIN: ESC_MARGIN, needsEscalation: needsEscalation, escalationPlan: escalationPlan, escalatedToLocalScale: escalatedToLocalScale, hiddenByClause: hiddenByClause, ruleFuzziness: ruleFuzziness, needsJevForQuality: needsJevForQuality, FUZZY_LMEAN_THRESHOLD: FUZZY_LMEAN_THRESHOLD, TRAIN_T: TRAIN_T, trainedSidesReady: trainedSidesReady, trainedHideProb: trainedHideProb, trainedSide: trainedSide, trainedToNative: trainedToNative, looCutoff: looCutoff, effectiveCutoff: effectiveCutoff, CUTOFF_MARGIN: CUTOFF_MARGIN, STRICTNESS_SPAN: STRICTNESS_SPAN, embCacheKey: embCacheKey, emaStep: emaStep, quantEmb: quantEmb, dequantEmb: dequantEmb, };
global.SuxShared = SuxShared; if (typeof module !== "undefined" && module.exports) { module.exports = SuxShared; } })(typeof globalThis !== "undefined" ? globalThis : this); return globalThis.SuxShared; })({});
/* * sux userscript — the extension's background page, content script, and * sidebar merged into one scope running on bsky.app. * * - Engine (was background.js): settings, classification queue, Jev HTTP * client via GM_xmlhttpRequest, verdict cache persisted with GM_setValue. * - Feed (was content.js): DOM post discovery, item mapping, hiding. * - Panel (was sidebar.*): floating settings panel in a closed Shadow DOM. * * Differences from the extension: no page-world fetch interceptor (it was a * supplement; the DOM scan is the source of truth), no browser-theme colors, * and the queue/stats are per tab. Settings and the verdict cache are shared * across tabs through userscript storage. */ /* global SuxShared, GM_xmlhttpRequest, GM_getValue, GM_setValue, GM_addValueChangeListener, GM_registerMenuCommand, unsafeWindow */
var SH = SuxShared;
/* ================= storage ================= */
function loadValue(name, fallback) { try { var v = GM_getValue(name, fallback); return v === undefined ? fallback : v; } catch (e) { return fallback; } }
function saveValue(name, value) { try { GM_setValue(name, value); } catch (e) { /* storage may be unavailable */ } }
/* ================= engine (was background.js) ================= */
var API_URL = "https://api.typesafe.ai/v1/systemone"; var CONCURRENCY = 2; var BATCH = 3; // posts per Jev request var MAX_ATTEMPTS = 5; var CACHE_CAP = 20000; // per-clause scores (a post with 3 clauses = 3 entries) var VERDICT_CAP = 10000; var REQUEST_TIMEOUT_MS = 30000;
var settings = SH.normalizeSettings(loadValue("settings", null)); var cache = new Map(); // clauseScoreKeys key -> score (persisted as "scores") var gen = 0; // bumps on every settings change; tags jobs var verdictsById = new Map(); // post id -> verdict (session only) var queue = []; // pending jobs, next first: {key, text, alt, ids, settings, gen, have, need} var running = 0; var pumpScheduled = false; var cacheWriteTimer = null; var settingsWriteTimer = null;
(function () { var stored = loadValue("scores", null); if (stored && typeof stored === "object") { Object.keys(stored).forEach(function (k) { cache.set(k, stored[k]); }); } saveValue("cache", null); // pre-0.2 verdict cache })();
var stats = { judged: 0, // verdicts served (API-computed + cache hits) 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 ---------------- */
// GM_xmlhttpRequest runs outside the page, so bsky.app's CSP and CORS don't apply. function gmPost(url, headers, body) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: "POST", url: url, headers: headers, data: body, timeout: REQUEST_TIMEOUT_MS, onload: function (r) { resolve(r); }, onerror: function () { reject(new Error("network error")); }, ontimeout: function () { reject(new Error("request timed out after " + REQUEST_TIMEOUT_MS / 1000 + "s")); }, onabort: function () { reject(new Error("request aborted")); }, }); }); }
function headerOf(raw, name) { var m = String(raw || "").match(new RegExp("^" + name + ":\\s*(.*)$", "im")); return m ? m[1].trim() : null; }
async function jevRequest(apiKey, payload) { var delay = 2000; for (var attempt = 1; ; attempt++) { var res = await gmPost(API_URL, { Authorization: "Bearer " + apiKey, "Content-Type": "application/json", }, JSON.stringify(payload)); 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 = headerOf(res.responseHeaders, "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.status < 200 || res.status >= 300) { var detail = String(res.responseText || "").slice(0, 200); throw new Error("Jev API HTTP " + res.status + (detail ? ": " + detail : "")); } return JSON.parse(res.responseText); } }
// Judge up to BATCH jobs that share one settings snapshot in a single Jev // call. Only the clauses each job still needs (the rest came from the score // cache) are asked. async function classifyBatch(jobs) { // Parse with the settings the requests were built with — the live settings // may have changed while the jobs were queued. var st = jobs[0].settings; var per = null; try { var payload = SH.buildBatchRequest(st, jobs.map(function (j) { return { post: { text: j.text, alt: j.alt }, only: j.need }; })); var res = await jevRequest(settings.apiKey, payload); var split = SH.splitBatchAnswers((res && res.answers) || null, jobs.length); per = split.map(function (a) { return SH.jevToScores(st, a); }); } catch (err) { recordError("classify", err); } jobs.forEach(function (job, k) { if (!per) { finish(job, null); return; } // A missing clause score isn't cached, so the next sighting asks again // instead of serving a fail-open forever. SH.clauseScoreKeys(st, job.text, job.alt).forEach(function (c) { if (typeof per[k][c.score] === "number") cache.set(c.key, per[k][c.score]); }); finish(job, Object.assign({}, job.have, per[k])); }); trimCache(); scheduleCacheWrite(); }
// Deliver a job's verdict to every post id waiting on it. scores null (or // undecidable) → fail open: a broken filter must not brick the feed. function finish(job, scores) { var st = job.settings; var verdict = scores ? SH.decideFromScores(st, scores) : null; var details = null; if (verdict === null) { if (scores) { recordError("classify", new Error( "Unexpected Jev response shape: " + JSON.stringify(scores).slice(0, 400))); } verdict = "show"; } else { details = SH.roundScores(scores); if (!Object.keys(details).length) details = null; } stats.judged++; for (var i = 0; i < job.ids.length; i++) { verdictsById.set(job.ids[i], { key: job.key, verdict: verdict }); if (verdictsById.size > VERDICT_CAP) { verdictsById.delete(verdictsById.keys().next().value); } } if (job.gen !== gen) return; // judged under rules we've since left onVerdicts(job.ids.map(function (id) { var item = { id: id, verdict: verdict }; if (details) 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) { // Same settings snapshot only — one request, one question set. var batch = [queue.shift()]; for (var i = 0; i < queue.length && batch.length < BATCH; ) { if (queue[i].settings === batch[0].settings) batch.push(queue.splice(i, 1)[0]); else i++; } running++; classifyBatch(batch) .catch(function (err) { recordError("classify", err); }) .then(function () { running--; schedulePump(); }); } }
// Returns an immediate verdict object, or null when the post is (now) // pending — its job is appended to `fresh` for the caller to enqueue. // 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, fresh) { 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 panel."; stats.lastErrorAt = Date.now(); var v = { id: p.id, verdict: "show" }; verdictsById.set(p.id, { key: key, verdict: v.verdict }); return v; } // Per-clause cache: whatever clauses are already scored for this text // count; only the rest go to Jev. var have = {}; var need = []; SH.clauseScoreKeys(settings, p.text, p.alt).forEach(function (c) { if (cache.has(c.key)) have[c.score] = cache.get(c.key); else need.push(c.id); }); if (!need.length) { var verdict = SH.decideFromScores(settings, have) || "show"; verdictsById.set(p.id, { key: key, verdict: verdict }); stats.judged++; var r = { id: p.id, verdict: verdict }; var d = SH.roundScores(have); if (Object.keys(d).length) r.d = d; return r; } var existing = null; var lists = [queue, fresh]; for (var l = 0; l < lists.length && !existing; l++) { for (var i = 0; i < lists[l].length; i++) { if (lists[l][i].key === key) { existing = lists[l][i]; break; } } } if (existing) { if (existing.ids.indexOf(p.id) === -1) existing.ids.push(p.id); } else { fresh.push({ key: key, text: p.text || "", alt: p.alt || "", ids: [p.id], settings: settings, // snapshot for request build + response parse gen: gen, have: have, need: need, }); } return null; // pending }
function testKey(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) }; } ); }
/* ---------------- persistence ---------------- */
function trimCache() { while (cache.size > CACHE_CAP) { cache.delete(cache.keys().next().value); } }
// Tabs share the cache: merge what other tabs stored before writing ours back. function scheduleCacheWrite() { if (cacheWriteTimer) return; cacheWriteTimer = setTimeout(function () { cacheWriteTimer = null; var stored = loadValue("scores", null); if (stored && typeof stored === "object") { Object.keys(stored).forEach(function (k) { if (!cache.has(k)) cache.set(k, stored[k]); }); trimCache(); } var obj = {}; cache.forEach(function (v, k) { obj[k] = v; }); saveValue("scores", obj); }, 2000); }
function scheduleSettingsWrite() { if (settingsWriteTimer) clearTimeout(settingsWriteTimer); settingsWriteTimer = setTimeout(function () { settingsWriteTimer = null; saveValue("settings", settings); }, 300); }
function setSettings(next, persist) { next = SH.normalizeSettings(next); // A save that changes nothing (e.g. typing in an example box) must not // restart judging. if (JSON.stringify(next) === JSON.stringify(settings)) return; settings = next; if (persist) scheduleSettingsWrite(); // Everything queued was built for the old rules; the feed re-requests // what's on screen under the new generation. gen++; queue.length = 0; onSettings(); panelSettingsChanged(!persist); }
// Another tab saved settings: adopt them here too. try { GM_addValueChangeListener("settings", function (name, oldValue, newValue, remote) { if (remote && newValue) setSettings(newValue, false); }); } catch (e) { /* manager without value listeners: tabs sync on reload */ }
/* ================= feed (was content.js) ================= */
var POSTS_CAP = 8000; var HIDDEN_ATTR = "data-sux-hidden"; var HIDDEN_CLASS = "sux-hidden"; var ACTION_SEL = '[aria-label^="Reply"], [aria-label^="Like"], [aria-label^="Repost"]';
/* ---- diagnostics (mirrored to page world) ---- */ var diag = { domExtracted: 0, // posts extracted from the DOM items: 0, // feed item elements currently tracked hidden: 0, // of those, currently hidden unmatchedAnchors: 0, // feed items we could not map to a post path nested: 0, // nested (quote/reply-preview) links on tracked items scans: 0, settingsActive: false, near50: 0, near50Samples: "", };
function publishDiag() { try { // Primitives/strings pass the cross-world boundary; objects don't. if (typeof unsafeWindow !== "undefined") unsafeWindow.__suxDiagJSON = JSON.stringify(diag); } catch (e) { /* ignore */ } }
/* ---- our hiding style ---- */ /* At document-start the <html> element may not exist yet; scan() retries. */ var style = document.createElement("style"); style.id = "sux-style"; style.textContent = "." + HIDDEN_CLASS + "{display:none!important}"; function ensureStyle() { if (!style.isConnected && document.documentElement) document.documentElement.appendChild(style); } ensureStyle();
/* ---- state ---- */ var active = SH.isActive(settings); diag.settingsActive = active;
var posts = new Map(); // post path ("profile/<author>/post/<rkey>") -> {id, text, alt} var verdicts = new Map(); // post path -> "show" | "hide" var scores = new Map(); // post path -> {p0?, n0?, ...} judgment scores (telemetry) var elements = new Map(); // feed item element -> {main, nested: []} var askedGen = new Map(); // post path -> generation it was last requested under var itemOf = new WeakMap(); // anchor -> its feed item (itemContainer is a DOM climb)
function indexPost(p) { if (!p || !p.id || posts.has(p.id)) return false; posts.set(p.id, p); if (posts.size > POSTS_CAP) { var old = posts.keys().next().value; posts.delete(old); askedGen.delete(old); } return true; }
// First usable score among enabled valid clauses: rule B nouls preferred, // then rule A probabilities. Detail keys are index-aligned with the clauses // (n0, n1, … / p0, p1, …), so a missing answer just falls through. function primaryScore(st, d) { var i, c; for (i = 0; i < st.rulesB.length; i++) { c = st.rulesB[i]; if (c.enabled && SH.clauseBValid(c) && typeof d["n" + i] === "number") return d["n" + i]; } for (i = 0; i < st.rulesA.length; i++) { c = st.rulesA[i]; if (c.enabled && SH.clauseAValid(c) && typeof d["p" + i] === "number") return d["p" + i]; } return null; }
/* ---- engine hand-off (was the Port to the background) ---- */
function requestVerdicts(list) { var items = []; var fresh = []; for (var i = 0; i < list.length; i++) { askedGen.set(list[i].id, gen); var r = resolvePost(list[i], fresh); if (r) items.push(r); } // Newest request first: it's what the user is looking at now. Each request // arrives nearest-the-viewport first. if (fresh.length) { Array.prototype.unshift.apply(queue, fresh); schedulePump(); } if (items.length) onVerdicts(items); }
function onSettings() { active = SH.isActive(settings); diag.settingsActive = active; // Old verdicts stay on screen until the new ones land — re-hiding // everything would blank the feed and refill it post by post. askedGen no // longer matches, so each post re-requests once. publishDiag(); reconcile(); }
function onVerdicts(items) { items.forEach(function (it) { verdicts.set(it.id, it.verdict); if (it.d) { scores.set(it.id, it.d); if (scores.size > 1000) scores.delete(scores.keys().next().value); } }); applyVerdicts(); }
/* ---- DOM mapping ---- */
// "/profile/<author>/post/<rkey>" from either relative or absolute hrefs, else null. function normalizePostPath(href) { if (!href || typeof href !== "string") return null; var h = href.replace(/^https?:\/\/[^/]+/i, "").split("?")[0].split("#")[0]; var m = h.match(/^\/profile\/([^/]+)\/post\/([^/]+)\/?$/); if (!m) return null; return "profile/" + m[1].toLowerCase() + "/post/" + m[2]; }
function normalizeProfilePath(href) { if (!href || typeof href !== "string") return null; var h = href.replace(/^https?:\/\/[^/]+/i, "").split("?")[0].split("#")[0]; var m = h.match(/^\/profile\/([^/]+)\/?$/); return m ? m[1].toLowerCase() : null; }
// A feed item is the ancestor whose parent holds at least two action-bar- // bearing children (sibling items in the virtualized list). Quote embeds and // reply previews have no action bar of their own, so every anchor in the item // — main or nested — climbs to the same wrapper. function itemContainer(anchor) { var node = anchor.parentElement; for (var depth = 0; node && node !== document.body && depth < 14; depth++) { var parent = node.parentElement; if (parent) { var kidsWithBars = 0; for (var i = 0; i < parent.children.length; i++) { if (parent.children[i].querySelector(ACTION_SEL)) kidsWithBars++; } if (kidsWithBars >= 2) return node; } node = parent; } return null; }
// Smallest block holding this anchor but no sibling post anchor — for a // nested (quote / reply-preview) anchor that is the embed box itself. function nestedContainer(anchor) { var node = anchor.parentElement; for (var depth = 0; node && node !== document.body && depth < 12; depth++) { var parent = node.parentElement; if (parent) { var siblings = parent.querySelectorAll('a[href*="/post/"]'); for (var i = 0; i < siblings.length; i++) { if (!node.contains(siblings[i])) return node; } } node = parent; } return anchor.parentElement; }
function depthWithin(root, el) { var d = 0; while (el && el !== root) { d++; el = el.parentElement; } return d; }
// Which post does this item represent? The main post's permalink is the anchor // whose author matches the item's dominant profile links; ties break on // shallowest depth, then document order. function chooseMainAnchor(item, anchors) { var counts = new Map(); var profileLinks = item.querySelectorAll('a[href*="/profile/"]'); for (var i = 0; i < profileLinks.length; i++) { var pp = normalizeProfilePath(profileLinks[i].getAttribute("href")); if (pp) counts.set(pp, (counts.get(pp) || 0) + 1); } var dominant = null; var best = 0; counts.forEach(function (n, author) { if (n > best) { best = n; dominant = author; } }); var fallback = null; var bestMatch = null; for (var j = 0; j < anchors.length; j++) { var a = anchors[j]; var path = normalizePostPath(a.getAttribute("href")); if (!path) continue; var rec = { a: a, depth: depthWithin(item, a), order: j }; if (!fallback || rec.depth < fallback.depth) fallback = rec; if (dominant && path.slice("profile/".length).split("/")[0] === dominant) { if (!bestMatch || rec.depth < bestMatch.depth) bestMatch = rec; } } var winner = bestMatch || fallback; return winner ? winner.a : null; }
/* ---- DOM text extraction ---- */
var ALT_CAP = 4000;
function itemAlts(item) { var out = []; var imgs = item.querySelectorAll("img"); for (var i = 0; i < imgs.length; i++) { var a = imgs[i].alt; if (a && a.trim()) out.push(a.trim()); } return out.join("\n").slice(0, ALT_CAP); }
// Header lines (repost credit, display name, handle, "· 19h") and the // trailing action-bar counts are noise for Jev; keep the post's own words. function cleanItemText(item) { var raw = item.innerText || ""; if (!raw.trim()) return ""; var lines = raw.split("\n"); var cut = -1; for (var i = 0; i < lines.length && i < 14; i++) { if (/^\s*·\s/.test(lines[i])) { cut = i; break; } } var body = cut >= 0 ? lines.slice(cut + 1) : lines; body = body.filter(function (l) { return !/^\s*reposted by\b/i.test(l); }); while (body.length) { var last = body[body.length - 1].trim(); if (last === "" || /^\d+([.,]\d+)?\s*[kKmM]?$/.test(last)) { body.pop(); } else break; } return body.join("\n").trim().slice(0, SH.TEXT_CAP); }
/* ---- hiding ---- */
function setHidden(el, hide) { if (el.classList.contains(HIDDEN_CLASS) === hide) return; if (hide) { el.setAttribute(HIDDEN_ATTR, "1"); el.classList.add(HIDDEN_CLASS); } else { el.removeAttribute(HIDDEN_ATTR); el.classList.remove(HIDDEN_CLASS); } }
// An item hides when its own post is not "show" (hide-until-judged), or when // any post it quotes/previews has an explicit "hide" verdict — no husks. function applyFor(item, rec) { if (!active) { setHidden(item, false); return; } var hide = verdicts.get(rec.main) !== "show"; if (!hide && rec.nested) { for (var i = 0; i < rec.nested.length; i++) { if (verdicts.get(rec.nested[i]) === "hide") { hide = true; break; } } } setHidden(item, hide); }
function applyVerdicts() { if (!active) return; elements.forEach(function (rec, el) { if (el.isConnected) applyFor(el, rec); }); }
function reconcile() { if (!active) { elements.forEach(function (rec, el) { setHidden(el, false); }); return; } requestVerdicts(onScreenPosts()); elements.forEach(function (rec, el) { if (el.isConnected) applyFor(el, rec); }); scheduleScan(); }
// Posts of tracked items still in the DOM and not yet asked about under the // current generation, nearest the viewport first — judged in that order. function onScreenPosts() { var mid = window.innerHeight / 2; var list = []; elements.forEach(function (rec, el) { if (!el.isConnected) return; var r = el.getBoundingClientRect(); var dist = Math.abs((r.top + r.bottom) / 2 - mid); [rec.main].concat(rec.nested || []).forEach(function (path) { var p = posts.get(path); if (p && askedGen.get(path) !== gen) list.push({ p: p, dist: dist }); }); }); list.sort(function (a, b) { return a.dist - b.dist; }); var seen = new Set(); return list.filter(function (x) { if (seen.has(x.p.id)) return false; seen.add(x.p.id); return true; }).map(function (x) { return x.p; }); }
/* ---- scanning ---- */
var scanScheduled = false; function scheduleScan() { if (scanScheduled) return; scanScheduled = true; // Next frame, before it paints: a freshly rendered post is hidden before it // is ever shown, and a burst of mutations costs one scan. requestAnimationFrame(function () { scanScheduled = false; scan(); }); }
function scan() { diag.scans++; ensureStyle(); elements.forEach(function (rec, el) { if (!el.isConnected) elements.delete(el); });
var anchors = document.querySelectorAll('a[href*="/post/"]'); var byItem = new Map(); for (var i = 0; i < anchors.length; i++) { if (!normalizePostPath(anchors[i].getAttribute("href"))) continue; // Cache only a sibling-confirmed item; the structural fallback can be // wrong while the list holds a single item, so it's recomputed. var item = itemOf.get(anchors[i]); if (!item || !item.isConnected || !item.contains(anchors[i])) { item = itemContainer(anchors[i]); if (item) itemOf.set(anchors[i], item); else item = nestedContainer(anchors[i]); // fallback: structural heuristic } if (!byItem.has(item)) byItem.set(item, []); byItem.get(item).push(anchors[i]); }
var unmatched = 0; var fresh = []; var nestedCount = 0; byItem.forEach(function (list, item) { var main = chooseMainAnchor(item, list); if (!main) { unmatched++; return; } var mainPath = normalizePostPath(main.getAttribute("href")); if (!mainPath) { unmatched++; return; } if (!posts.has(mainPath)) { var mp = { id: mainPath, text: cleanItemText(item), alt: itemAlts(item) }; if (indexPost(mp)) { diag.domExtracted++; fresh.push(mp); } } else if (askedGen.get(mainPath) !== gen) { fresh.push(posts.get(mainPath)); } var nested = []; for (var j = 0; j < list.length; j++) { var a = list[j]; if (a === main) continue; var np = normalizePostPath(a.getAttribute("href")); if (!np || np === mainPath || nested.indexOf(np) !== -1) continue; nested.push(np); if (!posts.has(np)) { var box = nestedContainer(a); var post = { id: np, text: cleanItemText(box), alt: itemAlts(box) }; if (indexPost(post)) { diag.domExtracted++; fresh.push(post); } } else if (askedGen.get(np) !== gen) { fresh.push(posts.get(np)); } } nestedCount += nested.length; var rec = { main: mainPath, nested: nested.length ? nested : null }; elements.set(item, rec); applyFor(item, rec); }); diag.items = elements.size; diag.hidden = countHidden(); diag.unmatchedAnchors = unmatched; diag.nested = nestedCount; // Near-threshold telemetry: which tracked posts sit close to the 50% line, // under the currently active clauses' scores. var near = 0; var samples = []; elements.forEach(function (rec) { var d = scores.get(rec.main); if (!d) return; var s = primaryScore(settings, d); if (typeof s !== "number") return; if (Math.abs(s - 0.5) <= 0.08) { near++; if (samples.length < 6) samples.push(rec.main.slice(-16) + "=" + s); } }); diag.near50 = near; diag.near50Samples = samples.join(", "); publishDiag(); if (fresh.length) { requestVerdicts(fresh.filter(function (p, i) { return fresh.indexOf(p) === i; })); } }
function countHidden() { var hidden = 0; elements.forEach(function (rec, el) { if (el.isConnected && el.classList.contains(HIDDEN_CLASS)) hidden++; }); return hidden; }
var observer = new MutationObserver(scheduleScan); observer.observe(document, { childList: true, subtree: true });
/* ================= panel (was sidebar.*) ================= */
var PANEL_CSS = ":host {\n all: initial;\n color-scheme: dark;\n /* Everforest dark (medium). No browser-theme API is available to userscripts. */\n --bg: #2d353b;\n --panel: #343f44;\n --border: #475258;\n --text: #d3c6aa;\n --muted: #859289;\n --accent: #7fbbb3;\n --error: #e67e80;\n position: fixed;\n right: 16px;\n bottom: 16px;\n z-index: 2147483647;\n font: 13px/1.45 system-ui, -apple-system, sans-serif;\n color: var(--text);\n}\n\n* { box-sizing: border-box; }\n\n#toggle {\n display: block;\n margin-left: auto;\n padding: 6px 12px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: var(--bg);\n color: var(--muted);\n font-weight: 600;\n letter-spacing: 0.04em;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);\n}\n\n#toggle.on { color: var(--accent); border-color: var(--accent); }\n\n#panel {\n width: 320px;\n max-width: calc(100vw - 32px);\n max-height: calc(100vh - 80px);\n overflow-y: auto;\n margin-bottom: 8px;\n margin-left: auto;\n padding: 12px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);\n}\n\n#panel[hidden] { display: none; }\n\nh1 {\n font-size: 15px;\n margin: 0;\n letter-spacing: 0.04em;\n}\n\nheader {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 12px;\n}\n\nheader h1 { flex: 1; }\n\nsection {\n background: var(--panel);\n border: 1px solid var(--border);\n border-radius: 8px;\n padding: 10px;\n margin-bottom: 10px;\n}\n\n.rule-title {\n display: flex;\n align-items: center;\n gap: 6px;\n font-weight: 600;\n margin-bottom: 8px;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 4px;\n}\n\n.clause {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 6px;\n}\n\n.clause.off { opacity: 0.5; }\n\n.clause .on { flex: 0 0 auto; }\n\n.clause select.dir { flex: 0 0 auto; }\n\nbutton.add {\n margin-top: 8px;\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--muted);\n padding: 4px 8px;\n font-size: 12px;\n}\n\nbutton.rm {\n background: none;\n border: none;\n color: var(--muted);\n padding: 0 2px;\n font-size: 16px;\n line-height: 1;\n flex: 0 0 auto;\n}\n\nbutton.rm:hover { color: var(--error); }\n\n.vs { color: var(--muted); }\n\ninput[type=\"text\"], input[type=\"password\"], input:not([type]), select {\n flex: 1;\n min-width: 0;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text);\n padding: 6px 8px;\n font: inherit;\n}\n\ninput:focus, select:focus { outline: 1px solid var(--accent); }\n\nbutton {\n background: var(--accent);\n border: none;\n border-radius: 6px;\n color: #fff;\n padding: 6px 10px;\n font: inherit;\n cursor: pointer;\n}\n\nbutton:disabled { opacity: 0.5; cursor: default; }\n\nbutton.icon {\n background: none;\n color: var(--muted);\n padding: 0 4px;\n font-size: 18px;\n line-height: 1;\n}\n\n.master {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0;\n font-weight: 600;\n color: var(--muted);\n}\n\n.master input:checked + span { color: var(--accent); }\n\nlabel { display: block; margin-bottom: 4px; }\n\np { margin: 4px 0; }\n\n.hint {\n min-height: 1em;\n margin: 6px 0 0;\n color: var(--error);\n font-size: 12px;\n}\n\n.hint.ok { color: #4caf7d; }\n\nfooter { color: var(--muted); }\n\nfooter b { color: var(--text); }\n\n.error { color: var(--error); min-height: 1em; margin: 4px 0; }\n\n.note { font-size: 12px; }\n\n/* Example rules stack their parts vertically inside the flex row. */\n.clause.e {\n flex-wrap: wrap;\n align-items: flex-start;\n border-top: 1px solid var(--border);\n padding-top: 6px;\n}\n\n.clause.e .ehead { display: flex; align-items: center; gap: 6px; width: 100%; }\n\n.clause.e .examples {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n width: 100%;\n margin: 6px 0 2px;\n}\n\n.chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 10px;\n padding: 2px 6px;\n font-size: 12px;\n max-width: 100%;\n}\n\n.chip .chiptext {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 220px;\n color: var(--muted);\n}\n\nbutton.chiprm {\n background: none;\n border: none;\n color: var(--muted);\n padding: 0;\n font-size: 13px;\n line-height: 1;\n}\n\nbutton.chiprm:hover { color: var(--error); }\n\n.clause.e .eadd { display: flex; gap: 6px; width: 100%; margin-top: 4px; }\n\nbutton.exadd {\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--muted);\n padding: 4px 10px;\n font-size: 13px;\n flex: 0 0 auto;\n}\n\nbutton.exadd:hover { color: var(--accent); border-color: var(--accent); }\n\n.clause.e .ectl {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n margin-top: 6px;\n}\n\n.clause.e .ctl-label {\n color: var(--muted);\n font-size: 11px;\n flex: 0 0 auto;\n}\n\n.clause.e input.strict { flex: 1; min-width: 0; }\n\n.clause.e .hidepct {\n color: var(--muted);\n font-size: 11px;\n flex: 0 0 auto;\n min-width: 0;\n}\n\n.jevbadge {\n flex: 0 0 auto;\n background: rgba(230, 126, 128, 0.15);\n border: 1px solid var(--error);\n border-radius: 8px;\n color: var(--error);\n font-size: 10px;\n padding: 1px 6px;\n white-space: nowrap;\n}\n\n.clause .on { flex: 0 0 auto; }\n\n.clause select.dir { flex: 0 0 auto; }\n\nbutton.add {\n margin-top: 8px;\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--muted);\n padding: 4px 8px;\n font-size: 12px;\n}\n\nbutton.rm {\n background: none;\n border: none;\n color: var(--muted);\n padding: 0 4px;\n font-size: 16px;\n line-height: 1;\n flex: 0 0 auto;\n}\n\nbutton.rm:hover { color: var(--error); }\n\n.vs { color: var(--muted); }\n\ninput[type=\"text\"], input[type=\"password\"], input:not([type]), select {\n flex: 1;\n min-width: 0;\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text);\n padding: 6px 8px;\n font: inherit;\n}\n\ninput:focus, select:focus { outline: 1px solid var(--accent); }\n\nbutton {\n background: var(--accent);\n border: none;\n border-radius: 6px;\n color: #fff;\n padding: 6px 10px;\n font: inherit;\n cursor: pointer;\n}\n\nbutton:disabled { opacity: 0.5; cursor: default; }\n\n.master {\n display: flex;\n align-items: center;\n gap: 6px;\n font-weight: 600;\n color: var(--muted);\n}\n\n.master input:checked + span { color: var(--accent); }\n\nlabel { display: block; margin-bottom: 4px; }\n\nlabel.mode {\n display: flex;\n gap: 6px;\n align-items: baseline;\n color: var(--muted);\n font-size: 12px;\n margin-bottom: 6px;\n cursor: pointer;\n}\n\nlabel.mode input { flex: 0 0 auto; }\nlabel.mode b { color: var(--text); font-weight: 600; }\nlabel.mode span { flex: 1; }\n\n.hint {\n min-height: 1em;\n margin: 6px 0 0;\n color: var(--error);\n font-size: 12px;\n}\n\n.hint.ok { color: #4caf7d; }\n\nfooter { color: var(--muted); }\n\nfooter b { color: var(--text); }\n\n.error { color: var(--error); min-height: 1em; margin: 4px 0; }\n\n.note { font-size: 12px; }\n"; var PANEL_HTML = "<div id=\"panel\" hidden>\n <header>\n <h1>sux</h1>\n <label class=\"master\" title=\"One press turns all filtering off\">\n <input type=\"checkbox\" id=\"enabled\">\n <span>Filtering on</span>\n </label>\n <button id=\"close\" type=\"button\" class=\"icon\" title=\"Close\">×</button>\n </header>\n\n <section>\n <div class=\"rule-title\"><span>Keep posts that lean toward…</span></div>\n <div id=\"rowsA\"></div>\n <button id=\"addA\" type=\"button\" class=\"add\">+ add clause</button>\n <p class=\"hint\" id=\"ruleAHint\"></p>\n </section>\n\n <section>\n <div class=\"rule-title\"><span>Keep posts that…</span></div>\n <div id=\"rowsB\"></div>\n <button id=\"addB\" type=\"button\" class=\"add\">+ add clause</button>\n <p class=\"hint\" id=\"ruleBHint\"></p>\n </section>\n\n <section>\n <div class=\"rule-title\"><span>Hide posts like these</span></div>\n <div id=\"rowsE\"></div>\n <button id=\"addE\" type=\"button\" class=\"add\">+ add rule</button>\n <p class=\"hint\" id=\"ruleEHint\"></p>\n </section>\n\n <section>\n <label for=\"combine\">When multiple clauses are on, a post passes if it meets</label>\n <select id=\"combine\">\n <option value=\"and\">all enabled clauses (AND)</option>\n <option value=\"or\">any enabled clause (OR)</option>\n </select>\n </section>\n\n <section>\n <label for=\"apiKey\">TypeSafe API key</label>\n <div class=\"row\">\n <input type=\"password\" id=\"apiKey\" autocomplete=\"off\" placeholder=\"ts-…\">\n <button id=\"testKey\" type=\"button\">Test</button>\n </div>\n <p class=\"hint\" id=\"testResult\"></p>\n </section>\n\n <footer>\n <p id=\"status\">judged <b id=\"stJudged\">0</b> · hidden <b id=\"stHidden\">0</b> · waiting <b id=\"stWaiting\">0</b></p>\n <p class=\"note\" id=\"diag\"></p>\n <p class=\"error\" id=\"lastError\"></p>\n <p class=\"note\">\n Posts stay hidden until Jev judges them. Judgments are cached, so reposts\n and re-scrolls cost nothing. With the filter off, the page is left untouched.\n </p>\n </footer>\n</div>\n<button id=\"toggle\" type=\"button\" title=\"sux settings\">sux</button>\n";
var fields = null; var panelSaveTimer = null;
function mountPanel() { var host = document.createElement("div"); host.id = "sux-panel-host"; var root = host.attachShadow({ mode: "closed" }); var css = document.createElement("style"); css.textContent = PANEL_CSS; root.appendChild(css); var wrap = document.createElement("div"); wrap.innerHTML = PANEL_HTML; while (wrap.firstChild) root.appendChild(wrap.firstChild);
// bsky.app has global keyboard shortcuts; typing in the panel must not trigger them. ["keydown", "keyup", "keypress"].forEach(function (type) { host.addEventListener(type, function (ev) { ev.stopPropagation(); }); });
function el(id) { return root.getElementById(id); } fields = { toggle: el("toggle"), panel: el("panel"), close: el("close"), enabled: el("enabled"), rowsA: el("rowsA"), rowsB: el("rowsB"), rowsE: el("rowsE"), addA: el("addA"), addB: el("addB"), addE: el("addE"), ruleAHint: el("ruleAHint"), ruleBHint: el("ruleBHint"), ruleEHint: el("ruleEHint"), combine: el("combine"), apiKey: el("apiKey"), testKey: el("testKey"), testResult: el("testResult"), stJudged: el("stJudged"), stHidden: el("stHidden"), stWaiting: el("stWaiting"), diag: el("diag"), lastError: el("lastError"), };
fields.toggle.addEventListener("click", function () { setPanelOpen(fields.panel.hidden); }); fields.close.addEventListener("click", function () { setPanelOpen(false); });
["enabled", "combine"].forEach(function (id) { fields[id].addEventListener("change", saveSoon); }); fields.addA.addEventListener("click", function () { addClause("A"); }); fields.addB.addEventListener("click", function () { addClause("B"); }); fields.addE.addEventListener("click", function () { addClause("E"); }); wireClauseContainer(fields.rowsA, "A"); wireClauseContainer(fields.rowsB, "B"); wireClauseContainer(fields.rowsE, "E"); fields.apiKey.addEventListener("input", function () { fields.testResult.textContent = ""; fields.testResult.classList.remove("ok"); saveSoon(); });
fields.testKey.addEventListener("click", function () { var key = fields.apiKey.value.trim(); fields.testResult.classList.remove("ok"); if (!key) { fields.testResult.textContent = "Enter an API key first."; return; } fields.testKey.disabled = true; fields.testResult.textContent = "Testing…"; testKey(key) .then(function (res) { if (res && res.ok) { fields.testResult.textContent = "Connected (" + (res.model || "jev") + ")."; fields.testResult.classList.add("ok"); } else { fields.testResult.textContent = (res && res.error) || "Test failed."; } }) .then(function () { fields.testKey.disabled = false; }); });
writeForm(settings); setPanelOpen(!!loadValue("panelOpen", false)); (document.body || document.documentElement).appendChild(host);
setInterval(refreshStats, 1000); refreshStats(); }
function setPanelOpen(open) { if (!fields) return; fields.panel.hidden = !open; saveValue("panelOpen", !!open); }
/* ---- clause rows (any number per rule type) ---- */
function toggleBox(checked) { var on = document.createElement("input"); on.type = "checkbox"; on.className = "on"; on.title = "Enable this clause"; on.checked = checked; return on; }
function wordInput(cls, placeholder, value) { var inp = document.createElement("input"); inp.type = "text"; inp.className = cls; inp.placeholder = placeholder; inp.autocomplete = "off"; inp.value = value; return inp; }
function removeBtn() { var btn = document.createElement("button"); btn.type = "button"; btn.className = "rm"; btn.title = "Remove this clause"; btn.textContent = "−"; return btn; }
function buildRowA(c, removable) { var row = document.createElement("div"); row.className = "clause" + (c.enabled ? "" : " off"); row.appendChild(toggleBox(c.enabled)); row.appendChild(wordInput("w1", "word 1", c.word1)); var vs = document.createElement("span"); vs.className = "vs"; vs.textContent = "vs"; row.appendChild(vs); row.appendChild(wordInput("w2", "word 2", c.word2)); if (removable) row.appendChild(removeBtn()); return row; }
function buildRowB(c, removable) { var row = document.createElement("div"); row.className = "clause" + (c.enabled ? "" : " off"); row.appendChild(toggleBox(c.enabled)); var dir = document.createElement("select"); dir.className = "dir"; dir.innerHTML = '<option value="above">are mostly</option>' + '<option value="below">are NOT</option>'; dir.value = c.above ? "above" : "below"; row.appendChild(dir); row.appendChild(wordInput("w", "word", c.word)); if (removable) row.appendChild(removeBtn()); return row; }
// Example rule (same shape as the extension's sidebar): a name, example // chips, paste-to-add. Strictness/cutoff/badge are extension-only (they need // the local model); the userscript judges example rules through Jev, so the // stored fields just round-trip via row._clause. function buildRowE(c, removable) { var row = document.createElement("div"); row.className = "clause e" + (c.enabled ? "" : " off"); row._clause = c; var head = document.createElement("div"); head.className = "ehead"; head.appendChild(toggleBox(c.enabled)); head.appendChild(wordInput("name", "rule name (optional)", c.name)); if (removable) head.appendChild(removeBtn()); row.appendChild(head);
var chips = document.createElement("div"); chips.className = "examples"; c.examples.forEach(function (x) { chips.appendChild(exampleChip(x)); }); row.appendChild(chips);
var addRow = document.createElement("div"); addRow.className = "eadd"; var inp = document.createElement("input"); inp.type = "text"; inp.className = "exin"; inp.placeholder = "paste a post you don't want to see"; inp.autocomplete = "off"; var addBtn = document.createElement("button"); addBtn.type = "button"; addBtn.className = "exadd"; addBtn.title = "Add as example"; addBtn.textContent = "+"; addRow.appendChild(inp); addRow.appendChild(addBtn); row.appendChild(addRow); return row; }
function exampleChip(x) { var chip = document.createElement("span"); chip.className = "chip"; var label = document.createElement("span"); label.className = "chiptext"; label.textContent = (x.text || x.alt || "").trim().slice(0, 60); chip.appendChild(label); var rm = document.createElement("button"); rm.type = "button"; rm.className = "chiprm"; rm.title = "Remove this example"; rm.textContent = "×"; chip.appendChild(rm); return chip; }
function renderRows(container, type, clauses) { container.textContent = ""; var removable = clauses.length > 1; for (var i = 0; i < clauses.length; i++) { container.appendChild( type === "A" ? buildRowA(clauses[i], removable) : type === "B" ? buildRowB(clauses[i], removable) : buildRowE(clauses[i], removable)); } }
function renderClauses(st) { renderRows(fields.rowsA, "A", st.rulesA); renderRows(fields.rowsB, "B", st.rulesB); renderRows(fields.rowsE, "E", st.rulesE); }
function readRows(container, type) { var out = []; var rows = container.children; for (var i = 0; i < rows.length; i++) { var r = rows[i]; if (type === "A") { out.push({ enabled: r.querySelector(".on").checked, word1: r.querySelector(".w1").value, word2: r.querySelector(".w2").value, }); } else if (type === "B") { out.push({ enabled: r.querySelector(".on").checked, word: r.querySelector(".w").value, above: r.querySelector(".dir").value !== "below", }); } else { // Preserve stored fields the panel doesn't edit (cutoff, strictness, // embeddings) — row._clause is the object renderClauses received. var prev = r._clause || { examples: [] }; var examples = []; var chips = r.querySelectorAll(".chip"); for (var j = 0; j < chips.length; j++) { var ex = prev.examples[j]; if (ex) examples.push({ text: ex.text, alt: ex.alt, emb: ex.emb }); } out.push({ enabled: r.querySelector(".on").checked, name: r.querySelector(".name").value, examples: examples, cutoff: prev.cutoff, strictness: prev.strictness, counters: prev.counters, }); } } return out; }
function addClause(type) { var form = readForm(); var clauses = type === "A" ? form.rulesA : type === "B" ? form.rulesB : form.rulesE; clauses.push(type === "A" ? { enabled: true, word1: "", word2: "" } : type === "B" ? { enabled: true, word: "", above: true } : { enabled: true, name: "", examples: [] }); renderRows( type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE, type, clauses); saveSoon(); var container = type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE; var focusTarget = container.lastElementChild && container.lastElementChild.querySelector("input.w1, input.w, input.name, select.dir"); if (focusTarget) focusTarget.focus(); }
function wireClauseContainer(container, type) { container.addEventListener("input", saveSoon); container.addEventListener("change", function (ev) { if (ev.target.classList.contains("on")) { ev.target.closest(".clause").classList.toggle("off", !ev.target.checked); } saveSoon(); }); container.addEventListener("click", function (ev) { var row = ev.target.closest ? ev.target.closest(".clause") : null; if (!row) return; if (ev.target.closest(".exadd")) { var inp = row.querySelector(".exin"); var text = inp.value.trim(); if (!text) return; row._clause = row._clause || { examples: [] }; row._clause.examples.push({ text: text.slice(0, SH.TEXT_CAP), alt: "" }); inp.value = ""; rerender(row, container, type); saveSoon(); return; } if (ev.target.closest(".chiprm")) { var chip = ev.target.closest(".chip"); var chips = chip.parentElement; var idx = Array.prototype.indexOf.call(chips.children, chip); if (idx !== -1) row._clause.examples.splice(idx, 1); rerender(row, container, type); saveSoon(); return; } var btn = ev.target.closest ? ev.target.closest(".rm") : null; if (!btn) return; var form = readForm(); var clauses = type === "A" ? form.rulesA : type === "B" ? form.rulesB : form.rulesE; var idx = Array.prototype.indexOf.call(container.children, row); if (idx !== -1) clauses.splice(idx, 1); renderRows( type === "A" ? fields.rowsA : type === "B" ? fields.rowsB : fields.rowsE, type, clauses); saveSoon(); }); container.addEventListener("keydown", function (ev) { if (ev.key !== "Enter" || !ev.target.classList.contains("exin")) return; ev.preventDefault(); var row = ev.target.closest(".clause"); var btn = row && row.querySelector(".exadd"); if (btn) btn.click(); }); }
function rerender(row, container, type) { var idx = Array.prototype.indexOf.call(container.children, row); if (idx === -1 || type !== "E") return; var fresh = buildRowE(row._clause, container.children.length > 1); container.replaceChild(fresh, row); var inp = fresh.querySelector(".exin"); if (inp) inp.focus(); }
function readForm() { return { enabled: fields.enabled.checked, apiKey: fields.apiKey.value.trim(), combine: fields.combine.value === "or" ? "or" : "and", // Fields the userscript panel doesn't edit (peek, mode) round-trip from // the live settings so a save never silently drops them. peek: settings.peek, mode: settings.mode, rulesA: readRows(fields.rowsA, "A"), rulesB: readRows(fields.rowsB, "B"), rulesE: readRows(fields.rowsE, "E"), }; }
function writeForm(s) { fields.enabled.checked = s.enabled; renderClauses(s); fields.combine.value = s.combine; fields.apiKey.value = s.apiKey; validate(); }
function clauseHint(clauses, isValid, msg) { var problems = []; for (var i = 0; i < clauses.length; i++) { if (clauses[i].enabled && !isValid(clauses[i])) { problems.push(clauses.length > 1 ? "clause " + (i + 1) + ": " + msg : msg); } } return problems.join("; "); }
function validate() { var st = SH.normalizeSettings(readForm()); fields.ruleAHint.textContent = clauseHint(st.rulesA, SH.clauseAValid, "needs two different words"); fields.ruleBHint.textContent = clauseHint(st.rulesB, SH.clauseBValid, "needs a word"); var eProblems = []; for (var i = 0; i < st.rulesE.length; i++) { if (st.rulesE[i].enabled && !SH.clauseEValid(st.rulesE[i])) { eProblems.push(st.rulesE.length > 1 ? "rule " + (i + 1) + ": add at least one example" : "add at least one example"); } } fields.ruleEHint.textContent = eProblems.join("; "); return st; }
// Every save re-judges the feed under the new rules, so typing waits for a // real pause; clicks and toggles save quickly. function saveSoon(ev) { validate(); if (panelSaveTimer) clearTimeout(panelSaveTimer); var typing = ev && ev.type === "input" && ev.target && ev.target.type === "text"; panelSaveTimer = setTimeout(function () { panelSaveTimer = null; setSettings(readForm(), true); }, typing ? 800 : 250); }
// fromElsewhere: settings arrived from another tab, so the form must follow. // Local edits are left alone — rewriting the form would clobber the caret. function panelSettingsChanged(fromElsewhere) { if (!fields) return; if (fromElsewhere) writeForm(settings); refreshStats(); }
function refreshStats() { if (!fields) return; fields.toggle.classList.toggle("on", active); fields.stJudged.textContent = stats.judged; diag.hidden = countHidden(); fields.stHidden.textContent = diag.hidden; fields.stWaiting.textContent = queue.length + running; fields.lastError.textContent = stats.lastError || ""; fields.diag.textContent = "feed: extracted " + diag.domExtracted + " · items " + diag.items + " · hidden " + diag.hidden + " · unmatched " + diag.unmatchedAnchors + " · active " + (diag.settingsActive ? "yes" : "no"); }
try { GM_registerMenuCommand("sux settings", function () { setPanelOpen(true); }); } catch (e) { /* menu commands unsupported */ }
if (document.body) { mountPanel(); } else { document.addEventListener("DOMContentLoaded", mountPanel, { once: true }); }})();