// ==UserScript== // @name Krillion Helper // @namespace https://krillion.io/ // @homepageURL https://github.com/YourAverageCow/krillion-helper // @supportURL https://github.com/YourAverageCow/krillion-helper/issues // @version 5.3 // @description Streams obscure answers for the prompt on screen from the AI of your choice (DeepSeek, OpenAI, Claude, Gemini, Groq, OpenRouter, Mistral, xAI, or a local model), learns real tiers from your results, and saves everything locally. // @match https://krillion.io/* // @downloadURL https://raw.githubusercontent.com/YourAverageCow/krillion-helper/main/krillion-helper.user.js // @updateURL https://raw.githubusercontent.com/YourAverageCow/krillion-helper/main/krillion-helper.user.js // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect api.deepseek.com // @connect api.openai.com // @connect api.anthropic.com // @connect generativelanguage.googleapis.com // @connect api.groq.com // @connect openrouter.ai // @connect api.mistral.ai // @connect api.x.ai // @connect localhost // @connect 127.0.0.1 // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ---------- Game tiers (from the game's own tier table) ---------- const TIERS = { plankton: { name: 'PLANKTON', score: 10 }, tooclever: { name: 'TOO CLEVER 🤡', score: 15 }, // a famous "obscure" pick everyone reaches for schooler: { name: 'SCHOOLER', score: 30 }, rare: { name: 'RARE', score: 60 }, deepcut: { name: 'DEEP CUT', score: 85 }, krillion: { name: 'KRILLION 🌟', score: 100 }, }; const STAR_SCORE = [0, 10, 30, 60, 85, 100]; // the AI's 1–5 guess mapped onto the same scale // ---------- AI providers ---------- // Everything except Claude speaks the OpenAI-style /chat/completions API. // Model lists are just suggestions — type any model name, or press "list models". const PROVIDERS = { deepseek: { label: 'DeepSeek', kind: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat', 'deepseek-reasoner'], keyUrl: 'https://platform.deepseek.com/api_keys' }, openai: { label: 'OpenAI', kind: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4.1-mini', models: ['gpt-4.1-mini', 'gpt-4.1', 'gpt-4o-mini', 'gpt-4o'], keyUrl: 'https://platform.openai.com/api-keys' }, anthropic: { label: 'Claude (Anthropic)', kind: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-opus-5-5', models: ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'], keyUrl: 'https://platform.claude.com/settings/keys' }, gemini: { label: 'Google Gemini', kind: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.5-flash', models: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'], keyUrl: 'https://aistudio.google.com/apikey' }, groq: { label: 'Groq (very fast)', kind: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'], keyUrl: 'https://console.groq.com/keys' }, openrouter: { label: 'OpenRouter (many models)', kind: 'openai', base: 'https://openrouter.ai/api/v1', model: 'deepseek/deepseek-chat', models: ['deepseek/deepseek-chat', 'anthropic/claude-sonnet-5-5', 'google/gemini-2.5-flash', 'meta-llama/llama-3.3-70b-instruct'], keyUrl: 'https://openrouter.ai/keys' }, mistral: { label: 'Mistral', kind: 'openai', base: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', models: ['mistral-large-latest', 'mistral-small-latest'], keyUrl: 'https://console.mistral.ai/api-keys' }, xai: { label: 'xAI Grok', kind: 'openai', base: 'https://api.x.ai/v1', model: 'grok-3-mini', models: ['grok-3-mini', 'grok-3'], keyUrl: 'https://console.x.ai' }, ollama: { label: 'Ollama (local)', kind: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1:8b', local: true, models: ['llama3.1:8b', 'qwen2.5:14b', 'gemma2:9b', 'mistral-nemo'] }, lmstudio: { label: 'LM Studio (local)', kind: 'openai', base: 'http://localhost:1234/v1', model: '', local: true, models: [] }, custom: { label: 'Custom (OpenAI-compatible URL)', kind: 'openai', base: '', model: '', local: true, models: [] }, }; // ---------- Settings ---------- const DEFAULTS = { provider: 'deepseek', models: {}, // per-provider model override baseUrls: {}, // per-provider base URL override (local / custom) count: 10, // answers per request depth: 'balanced', // safe | balanced | deep — how obscure to aim temperature: 0.7, sendTemperature: true, // some models reject it; the script also retries without it automatically effort: 'low', // Claude only: low is fastest timeoutSec: 45, extraInstructions: '', // appended to the AI instructions autoAsk: true, // ask automatically when a new prompt appears autoSubmit: false, // choosing a suggestion also presses Enter hideSaid: true, // hide (instead of strike through) answers already said in Puffer Party hideMisses: true, // hide answers the game rejected before minStars: 1, // only show answers at least this rare verify: true, // second AI pass that drops answers that don't fit the prompt refillBelow: 3, // fetch more automatically when fewer unused suggestions than this remain (0 = off) autoAnswer: false, // solo modes only: submit the best answer on a timer autoDelay: 3, // seconds to wait once a round opens autoJitter: 1.5, // extra random 0–N seconds autoRetry: 1.5, // seconds between attempts after a rejected answer corner: 'br', // br | bl | tr | tl pos: null, // {left, top} once dragged width: 310, fontSize: 15, opacity: 0.93, showHints: true, }; const loaded = GM_getValue('settings', {}); const settings = Object.assign({}, DEFAULTS, loaded); // carry over v1–v4 settings if (loaded.model && !settings.models.deepseek) settings.models.deepseek = loaded.model; delete settings.model; const saveSettings = () => GM_setValue('settings', settings); const keys = GM_getValue('keys', {}); const oldKey = GM_getValue('deepseekKey', ''); if (oldKey && !keys.deepseek) { keys.deepseek = oldKey; GM_setValue('keys', keys); } const prov = () => PROVIDERS[settings.provider] || PROVIDERS.deepseek; const provModel = () => settings.models[settings.provider] || prov().model; const provBase = () => (settings.baseUrls[settings.provider] || prov().base).replace(/\/+$/, ''); const provKey = () => keys[settings.provider] || ''; // ---------- Saved answers (persist across sessions) ---------- // { normalisedPrompt: { prompt, at, answers: [{answer, rarity, tier?, miss?, canonical?, mine?, doubt?, checked?}] } } let store = GM_getValue('answers', {}); const norm = (s) => String(s).toLowerCase().replace(/[“”"]/g, '').replace(/\s+/g, ' ').trim(); const saveStore = () => GM_setValue('answers', store); const entryFor = (prompt) => (store[norm(prompt)] ||= { prompt, at: Date.now(), answers: [] }); const findAnswer = (entry, text) => { const n = norm(text); return entry.answers.find((a) => norm(a.answer) === n || (a.canonical && norm(a.canonical) === n)); }; // ---------- State ---------- let lastPrompt = null; let pending = false; let verifying = false; let lastSubmitted = null; // what was in the game's input when Enter was pressed let visible = []; // answers currently shown, in order (for shortcuts) let cursor = -1; // Tab-cycling position let lastLatency = null; // seconds until the first suggestion arrived const partySaid = new Set(); // normalised answers accepted in the current party category const handled = new WeakSet(); const refills = new Map(); // prompt → automatic refills this page load // ---------- API key ---------- const keyAsked = new Set(); // only pop up the key box once per provider per page load function getKey(forcePrompt) { let key = provKey(); if (forcePrompt || (!key && !prov().local && !keyAsked.has(settings.provider))) { keyAsked.add(settings.provider); const hint = prov().keyUrl ? `\n(get one at ${prov().keyUrl})` : '\n(leave blank if your server needs none)'; const input = prompt(`${prov().label} API key — stored only in your userscript manager:${hint}`, key); if (input === null) return key; key = input.trim(); keys[settings.provider] = key; GM_setValue('keys', keys); } return key; } GM_registerMenuCommand('Set API key for current provider', () => getKey(true)); GM_registerMenuCommand('Show / hide helper panel', () => panel.classList.toggle('hidden')); // ---------- LLM request layer ---------- const errText = (raw) => { try { const j = JSON.parse(raw); const e = Array.isArray(j) ? j[0]?.error : j.error; return (e && (e.message || e)) || j.message || ''; } catch (e) { return String(raw || '').slice(0, 160); } }; const supportsFallbacks = (m) => /^claude-(opus-5|fable-5-1|sonnet-5-5)/.test(m); // Calls the chosen provider. onDelta(text) gets streamed text; onDone(err, fullText) at the end. function llmRequest({ system, user, stream, temperature, onDelta, onDone, retried }) { const p = prov(); const model = provModel(); const key = provKey(); if (!model) return onDone('Set a model name in ⚙ (or press "list models").'); let url, headers = { 'Content-Type': 'application/json' }, body; if (p.kind === 'anthropic') { url = provBase() + '/v1/messages'; headers['x-api-key'] = key; headers['anthropic-version'] = '2026-10-21'; headers['anthropic-dangerous-direct-browser-access'] = 'true'; body = { model, max_tokens: 16000, system, stream, messages: [{ role: 'user', content: user }] }; if (/^claude-(opus|sonnet|haiku|fable)-5/.test(model)) body.output_config = { effort: settings.effort }; if (supportsFallbacks(model)) { // re-run a declined request on Anthropic's recommended fallback model headers['anthropic-beta'] = 'server-side-fallback-2026-07-01'; body.fallbacks = 'default'; } } else { url = provBase() + '/chat/completions'; if (key) headers.Authorization = 'Bearer ' + key; if (settings.provider === 'openrouter') { headers['HTTP-Referer'] = 'https://krillion.io'; headers['X-Title'] = 'Krillion Helper'; } body = { model, stream, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }; const noTemp = /reasoner|^o\d|gpt-5/.test(model); if (temperature != null && settings.sendTemperature && !noTemp && !retried) body.temperature = temperature; } let full = '', refusal = false, streamErr = ''; const handleEvent = (j) => { if (j.error) { streamErr = j.error.message || String(j.error); return; } let t = ''; if (p.kind === 'anthropic') { if (j.type === 'content_block_delta' && j.delta && j.delta.type === 'text_delta') t = j.delta.text; if (j.type === 'message_delta' && j.delta && j.delta.stop_reason === 'refusal') refusal = true; } else { t = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content; } if (t) { full += t; onDelta && onDelta(t); } }; let seen = 0, buf = '', streamed = false, finished = false; const feed = (raw) => { if (!raw || raw.length <= seen) return; buf += raw.slice(seen); seen = raw.length; let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line.startsWith('data:')) continue; const d = line.slice(5).trim(); if (!d || d === '[DONE]') continue; try { handleEvent(JSON.parse(d)); } catch (e) { /* partial or keep-alive line */ } } }; const finish = (status, raw) => { if (finished) return; finished = true; if (status && status !== 200) { // some models reject temperature: retry once without it if (status === 400 && !retried && body.temperature != null) { return llmRequest({ system, user, stream, temperature, onDelta, onDone, retried: true }); } const why = errText(raw); const hints = { 401: 'API key rejected', 402: 'out of credit', 403: p.local ? 'blocked by the local server (see Ollama tip in ⚙)' : 'access denied', 404: 'model or URL not found', 429: 'rate limited — slow down or check your plan' }; return onDone(`${p.label}: ${hints[status] || 'error ' + status}${why ? ' — ' + why : ''}`); } if (stream) { feed((raw || '') + '\n'); // a provider that can't stream (or errors early) replies with plain JSON instead of events if (!full && !streamErr && /^\s*[[{]/.test(raw || '')) { const why = errText(raw); if (why) return onDone(`${p.label}: ${why}`); } } else { try { const j = JSON.parse(raw); if (p.kind === 'anthropic') { if (j.stop_reason === 'refusal') refusal = true; full = (j.content || []).filter((b) => b.type === 'text').map((b) => b.text).join(''); } else full = j.choices[0].message.content || ''; } catch (e) { return onDone(`${p.label}: couldn't read the reply.`); } } if (streamErr) return onDone(`${p.label}: ${streamErr}`); if (refusal && !full) return onDone(`${p.label} declined this prompt.`); onDone(null, full); }; GM_xmlhttpRequest({ method: 'POST', url, headers, data: JSON.stringify(body), responseType: stream ? 'stream' : undefined, // Tampermonkey streams; other managers fall back to onprogress/onload timeout: settings.timeoutSec * 1000 * (/reasoner|pro|opus|fable/.test(model) ? 2 : 1), async onloadstart(res) { if (!stream) return; const s = res.response; if (!s || typeof s.getReader !== 'function') return; streamed = true; const reader = s.getReader(); const dec = new TextDecoder(); let raw = ''; try { for (;;) { const { done, value } = await reader.read(); if (done) break; raw += dec.decode(value, { stream: true }); if (!res.status || res.status === 200) feed(raw); } } catch (e) { /* connection dropped */ } finish(res.status, raw); }, onprogress(res) { if (stream && !streamed && typeof res.responseText === 'string') feed(res.responseText); }, onload(res) { if (!streamed) finish(res.status, typeof res.responseText === 'string' ? res.responseText : ''); }, onerror() { if (finished) return; finished = true; onDone(p.local ? `Can't reach ${provBase()} — is the local server running?` : `Network error reaching ${p.label}.`); }, ontimeout() { if (!finished) { finished = true; onDone(`${p.label} timed out — try again or raise the timeout in ⚙.`); } }, }); } function listModels(cb) { const p = prov(); const headers = {}; const key = provKey(); let url; if (p.kind === 'anthropic') { url = provBase() + '/v1/models'; headers['x-api-key'] = key; headers['anthropic-version'] = '2026-10-21'; headers['anthropic-dangerous-direct-browser-access'] = 'true'; } else { url = provBase() + '/models'; if (key) headers.Authorization = 'Bearer ' + key; } GM_xmlhttpRequest({ method: 'GET', url, headers, timeout: 15000, onload(res) { if (res.status !== 200) return cb(`couldn't list models (${res.status}) ${errText(res.responseText)}`); try { const j = JSON.parse(res.responseText); const ids = (j.data || j.models || []).map((m) => (m.id || m.name || '').replace(/^models\//, '')).filter(Boolean); cb(null, ids.sort()); } catch (e) { cb("couldn't read the model list"); } }, onerror() { cb(p.local ? `can't reach ${provBase()} — is it running?` : 'network error'); }, ontimeout() { cb('timed out'); }, }); } // ---------- Panel ---------- const style = document.createElement('style'); style.textContent = ` #kr-helper{position:fixed;z-index:99999;max-height:70vh;display:flex;flex-direction:column; background:rgba(11,20,40,var(--kr-op,.93));color:#e8eef5;border:1px solid #4de3ff;border-radius:8px; font-family:var(--font-vt323, ui-monospace, monospace);line-height:1.3;box-shadow:0 4px 18px #0008} #kr-helper.hidden{display:none} #kr-helper header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px; background:#4de3ff22;cursor:move;user-select:none} #kr-helper .body{padding:8px 10px;overflow:auto} #kr-helper.collapsed .body,#kr-helper.collapsed .settings{display:none} #kr-helper .q{opacity:.8;margin-bottom:2px} #kr-helper .meta{opacity:.55;font-size:.87em;margin-bottom:6px} #kr-helper .chip{display:flex;align-items:center;gap:8px;width:100%;margin:3px 0;padding:4px 8px; background:#13204a;color:inherit;border:1px solid #2a3c7a;border-radius:5px;font:inherit;text-align:left;cursor:pointer} #kr-helper .chip:hover,#kr-helper .chip.cur{border-color:#ffd166;background:#1d2d63} #kr-helper .chip.said,#kr-helper .chip.miss{opacity:.4;text-decoration:line-through} #kr-helper .n{opacity:.45;width:1em} #kr-helper .w{flex:1} #kr-helper .r{color:#ffd166;white-space:nowrap;font-size:.87em} #kr-helper .r.real{color:var(--tc,#ffd166);font-weight:bold} #kr-helper .err{color:#ff6b6b} #kr-helper .auto{color:#7dffb0;font-size:.93em;margin-bottom:4px} #kr-helper .auto:empty{display:none} #kr-helper .said-list,#kr-helper .keys{margin-top:8px;font-size:.87em;opacity:.6} #kr-helper.no-hints .keys{display:none} #kr-helper .btn{background:none;border:none;color:#4de3ff;font:inherit;cursor:pointer;padding:0 3px} #kr-helper .more{width:100%;margin-top:6px;padding:4px;background:#4de3ff22;color:#4de3ff;border:1px dashed #4de3ff; border-radius:5px;font:inherit;cursor:pointer} #kr-helper .settings{display:none;padding:8px 10px;border-top:1px solid #2a3c7a;font-size:.93em;overflow:auto} #kr-helper.show-settings .settings{display:block} #kr-helper.show-settings .body{max-height:30vh} #kr-helper .settings h4{margin:10px 0 4px;color:#4de3ff;font-weight:normal;letter-spacing:.05em} #kr-helper .settings label{display:flex;justify-content:space-between;align-items:center;margin:4px 0;gap:8px} #kr-helper .settings input[type=number],#kr-helper .settings input[type=text],#kr-helper .settings select{width:140px; background:#13204a;color:inherit;border:1px solid #2a3c7a;border-radius:4px;font:inherit} #kr-helper .settings textarea{width:100%;box-sizing:border-box;min-height:48px;background:#13204a;color:inherit; border:1px solid #2a3c7a;border-radius:4px;font:inherit} #kr-helper .settings .row{display:flex;gap:6px;margin-top:6px} #kr-helper .settings .row button{flex:1;background:#13204a;color:inherit;border:1px solid #2a3c7a;border-radius:4px;font:inherit;cursor:pointer} #kr-helper .tip{font-size:.85em;opacity:.6;margin:2px 0 6px} `; document.head.appendChild(style); const providerOptions = Object.entries(PROVIDERS).map(([id, p]) => ``).join(''); const panel = document.createElement('div'); panel.id = 'kr-helper'; panel.innerHTML = `
🦐 krillion helper
waiting for a prompt…
Tab / Shift+Tab: cycle · Alt+1–9: pick · Alt+R: more · Alt+H: hide

AI

ANSWERS

extra instructions for the AI

AUTO-ANSWER (solo modes)

LOOK

SAVED ANSWERS

`; document.body.appendChild(panel); const $ = (sel) => panel.querySelector(sel); const qEl = $('.q'), metaEl = $('.meta'), listEl = $('.list'), moreBtn = $('.more'), saidEl = $('.said-list'); function applyLook() { panel.style.width = settings.width + 'px'; panel.style.fontSize = settings.fontSize + 'px'; panel.style.setProperty('--kr-op', settings.opacity); panel.classList.toggle('no-hints', !settings.showHints); for (const s of ['left', 'right', 'top', 'bottom']) panel.style[s] = ''; if (settings.pos) { panel.style.left = Math.min(Math.max(0, settings.pos.left), innerWidth - 80) + 'px'; panel.style.top = Math.min(Math.max(0, settings.pos.top), innerHeight - 40) + 'px'; } else { panel.style[settings.corner.includes('b') ? 'bottom' : 'top'] = '12px'; panel.style[settings.corner.includes('r') ? 'right' : 'left'] = '12px'; } } function syncProviderFields() { const p = prov(); $('[data-p="model"]').value = provModel(); $('[data-p="base"]').value = provBase(); $('.base-row').style.display = p.local ? '' : 'none'; $('.effort-row').style.display = p.kind === 'anthropic' ? '' : 'none'; setModelOptions(p.models); $('.prov-tip').textContent = providerTip(); } const TIPS = { ollama: 'Install Ollama, run "ollama pull llama3.1:8b". If you get "blocked", start it with OLLAMA_ORIGINS=* set. Bigger models give better answers.', lmstudio: 'In LM Studio, load a model and start the local server (Developer tab), then press "list models".', custom: 'Any OpenAI-compatible server: enter its base URL (ending in /v1) and a model name.', anthropic: 'Opus is the smartest; Haiku is fastest and cheapest. Effort "low" keeps it quick.', groq: 'Groq is extremely fast, good for auto-answer.', openrouter: 'One key for hundreds of models — press "list models" to browse.', }; const providerTip = () => TIPS[settings.provider] || (prov().keyUrl ? 'Get a key: ' + prov().keyUrl : ''); function setModelOptions(ids) { const dl = $('#kr-models'); dl.textContent = ''; for (const m of ids) { const o = document.createElement('option'); o.value = m; dl.appendChild(o); } } panel.querySelectorAll('[data-s]').forEach((el) => { const k = el.dataset.s; if (el.type === 'checkbox') el.checked = settings[k]; else el.value = settings[k]; el.addEventListener('change', () => { settings[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value; saveSettings(); if (k === 'provider') syncProviderFields(); applyLook(); if (lastPrompt) render(lastPrompt); }); }); $('[data-p="model"]').addEventListener('change', (e) => { settings.models[settings.provider] = e.target.value.trim(); saveSettings(); }); $('[data-p="base"]').addEventListener('change', (e) => { settings.baseUrls[settings.provider] = e.target.value.trim(); saveSettings(); }); syncProviderFields(); applyLook(); const updateSavedCount = () => { const entries = Object.values(store); const learned = entries.reduce((n, e) => n + e.answers.filter((a) => a.tier || a.miss).length, 0); const hits = entries.reduce((n, e) => n + e.answers.filter((a) => a.tier && TIERS[a.tier].score >= 60).length, 0); $('.saved-count').textContent = `${entries.length} prompts saved · ${learned} real results · ${hits} rare-or-better hits`; }; updateSavedCount(); function download(name, data) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 1000); } // merge imported answers into the store, keeping real results over guesses // only copy known fields with valid values, so a bad file can't break the panel function cleanAnswer(a) { if (!a || typeof a.answer !== 'string' || !a.answer.trim() || a.answer.length > 80) return null; const out = { answer: a.answer.trim(), rarity: Math.max(0, Math.min(5, a.rarity | 0)) }; if (TIERS[a.tier]) out.tier = a.tier; else if (a.miss === true) out.miss = true; if (typeof a.canonical === 'string' && a.canonical.length <= 80) out.canonical = a.canonical; for (const f of ['mine', 'checked']) if (a[f] === true) out[f] = true; if (typeof a.doubt === 'string') out.doubt = a.doubt.slice(0, 40); return out; } function importStore(data) { if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('not an export'); let prompts = 0, added = 0; for (const [rawKey, e] of Object.entries(data)) { if (!e || !Array.isArray(e.answers)) continue; const k = norm(typeof e.prompt === 'string' ? e.prompt : rawKey); if (!k || ['__proto__', 'constructor', 'prototype'].includes(k)) continue; prompts++; if (!Object.hasOwn(store, k)) store[k] = { prompt: String(e.prompt || rawKey).slice(0, 300), at: Date.now(), answers: [] }; const mine = store[k]; for (const raw of e.answers) { const a = cleanAnswer(raw); if (!a) continue; const have = findAnswer(mine, a.answer); if (!have) { mine.answers.push(a); added++; } else if ((a.tier || a.miss) && !(have.tier || have.miss)) Object.assign(have, a); } } saveStore(); updateSavedCount(); return `imported ${added} answers across ${prompts} prompts`; } const settingsMsg = (text, isErr) => { const el = $('.prov-tip'); el.textContent = text; el.style.color = isErr ? '#ff6b6b' : '#7dffb0'; el.style.opacity = 1; clearTimeout(settingsMsg.timer); settingsMsg.timer = setTimeout(() => { el.style.color = ''; el.style.opacity = ''; el.textContent = providerTip(); }, 6000); }; panel.addEventListener('click', (e) => { const a = e.target.dataset && e.target.dataset.a; if (!a) return; e.stopPropagation(); if (a === 'toggle') panel.classList.toggle('collapsed'); else if (a === 'settings') { panel.classList.remove('collapsed'); panel.classList.toggle('show-settings'); } else if (a === 'ask' && lastPrompt) ask(lastPrompt, false); else if (a === 'key') getKey(true); else if (a === 'models') { settingsMsg('listing models…'); listModels((err, ids) => { if (err) return settingsMsg(err, true); setModelOptions(ids); settingsMsg(`${ids.length} models found — click the model box to pick one`); }); } else if (a === 'test') { settingsMsg('testing…'); const t0 = Date.now(); llmRequest({ system: 'Reply with exactly: OK', user: 'Say OK.', stream: false, temperature: 0, onDone: (err, text) => err ? settingsMsg(err, true) : settingsMsg(`✓ ${prov().label} / ${provModel()} replied "${text.trim().slice(0, 20)}" in ${((Date.now() - t0) / 1000).toFixed(1)}s`), }); } else if (a === 'clear-prompt' && lastPrompt) { delete store[norm(lastPrompt)]; saveStore(); updateSavedCount(); render(lastPrompt); } else if (a === 'clear-all' && confirm('Delete all saved answers?')) { store = {}; saveStore(); updateSavedCount(); if (lastPrompt) render(lastPrompt); } else if (a === 'export') download('krillion-saved-answers.json', store); else if (a === 'import') $('.import-file').click(); else if (a === 'reset-pos') { settings.pos = null; saveSettings(); applyLook(); } else if (a === 'reset-settings' && confirm('Reset all settings (keeps saved answers and keys)?')) { GM_setValue('settings', {}); location.reload(); } }); $('.import-file').addEventListener('change', (e) => { const f = e.target.files[0]; if (!f) return; f.text().then((t) => { try { settingsMsg(importStore(JSON.parse(t))); if (lastPrompt) render(lastPrompt); } catch (err) { settingsMsg("that file isn't a saved-answers export", true); } e.target.value = ''; }); }); moreBtn.addEventListener('click', () => lastPrompt && ask(lastPrompt, true)); // drag the panel by its header; a click without movement collapses it (function makeDraggable() { const header = $('header'); let start = null; header.addEventListener('mousedown', (e) => { if (e.target.dataset.a || e.button !== 0) return; const r = panel.getBoundingClientRect(); start = { x: e.clientX, y: e.clientY, left: r.left, top: r.top, moved: false }; e.preventDefault(); }); addEventListener('mousemove', (e) => { if (!start) return; const dx = e.clientX - start.x, dy = e.clientY - start.y; if (!start.moved && Math.abs(dx) + Math.abs(dy) < 4) return; start.moved = true; settings.pos = { left: start.left + dx, top: start.top + dy }; applyLook(); }); addEventListener('mouseup', () => { if (!start) return; if (start.moved) saveSettings(); else panel.classList.toggle('collapsed'); start = null; }); addEventListener('resize', applyLook); })(); // ---------- Filling the game's input ---------- const gameInput = () => document.querySelector('input.ds-input'); function fillInput(text, submit) { const input = gameInput(); if (!input) return; // React tracks the value itself, so use the native setter and fire an input event. const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; setter.call(input, text); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); if (submit) { setTimeout(() => { lastSubmitted = text; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); try { if (input.form) input.form.requestSubmit(); } catch (e) { /* older browsers: the Enter key event covers it */ } }, 30); } } // ---------- Rendering ---------- const answerScore = (a) => (a.tier ? TIERS[a.tier].score : STAR_SCORE[Math.max(1, Math.min(5, a.rarity | 0))]); const isSaid = (a) => partySaid.has(norm(a.answer)) || (a.canonical && partySaid.has(norm(a.canonical))); function render(prompt, msg, isErr) { qEl.textContent = prompt; const entry = store[norm(prompt)]; const answers = entry ? entry.answers : []; const learned = answers.filter((a) => a.tier || a.miss).length; metaEl.textContent = [ `${prov().label.replace(/ \(.*\)/, '')}${provModel() ? ' · ' + provModel() : ''}`, entry ? `${answers.length} saved` : '', learned ? `${learned} confirmed` : '', pending ? 'streaming…' : lastLatency ? `first in ${lastLatency}s` : '', verifying ? 'double-checking…' : '', ].filter(Boolean).join(' · '); listEl.textContent = ''; if (msg) { const d = document.createElement('div'); if (isErr) d.className = 'err'; d.textContent = msg; listEl.appendChild(d); } visible = answers .filter((a) => !(a.miss && settings.hideMisses)) .filter((a) => !a.doubt || a.tier) .filter((a) => !(settings.hideSaid && isSaid(a))) .filter((a) => a.tier || (a.rarity | 0) >= settings.minStars || a.mine) .sort((a, b) => (b.miss ? -1 : answerScore(b)) - (a.miss ? -1 : answerScore(a)) || (b.tier ? 2 : b.checked ? 1 : 0) - (a.tier ? 2 : a.checked ? 1 : 0)); if (cursor >= visible.length) cursor = -1; visible.forEach((a, i) => { const b = document.createElement('button'); b.className = 'chip' + (isSaid(a) ? ' said' : '') + (a.miss ? ' miss' : '') + (i === cursor ? ' cur' : ''); b.innerHTML = ''; b.querySelector('.n').textContent = i < 9 ? String(i + 1) : ''; b.querySelector('.w').textContent = a.answer + (a.canonical && norm(a.canonical) !== norm(a.answer) ? ` → ${a.canonical}` : ''); const r = b.querySelector('.r'); if (a.miss) r.textContent = '✗ not accepted'; else if (a.tier) { r.className = 'r real'; r.style.setProperty('--tc', `var(--tier-${a.tier})`); r.textContent = '✓ ' + TIERS[a.tier].name; } else r.textContent = (a.checked ? '✔ ' : '') + '★'.repeat(Math.max(1, Math.min(5, a.rarity | 0))); b.addEventListener('click', () => fillInput(a.answer, settings.autoSubmit)); listEl.appendChild(b); }); moreBtn.style.display = entry && !pending ? 'block' : 'none'; saidEl.textContent = partySaid.size ? `said in party (${partySaid.size}): ${[...partySaid].join(', ')}` : ''; maybeRefill(prompt); } // keep a few unused suggestions in reserve function maybeRefill(prompt) { if (!settings.refillBelow || pending || verifying || prompt !== lastPrompt) return; const entry = store[norm(prompt)]; const done = refills.get(prompt) || 0; if (!entry || !entry.answers.length || done >= 3) return; // at most 3 automatic refills per prompt per page load const unused = visible.filter((a) => !a.miss && !a.tier && !isSaid(a)).length; if (unused < settings.refillBelow) { refills.set(prompt, done + 1); setTimeout(() => ask(prompt, true), 0); } } // ---------- Local prompt checks ---------- // Letter/length rules in a prompt can be checked without asking the AI, so obvious misfits never show. function promptRules(prompt) { const p = prompt.toLowerCase(); const rules = []; const letters = (s) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, ''); const filler = '(?!(?:the|same|any|one|its|their|his|her|two|no)\\b)'; const start = p.match(new RegExp(`(?:start|begin)s?(?:ing)? with (?:the letters? |an? )?["'“]?${filler}([a-z]{1,4})\\b`)); if (start) rules.push((a) => letters(a).startsWith(start[1])); const end = p.match(new RegExp(`end(?:s|ing)? (?:with|in) (?:the letters? |an? )?["'“]?${filler}([a-z]{1,4})\\b`)); if (end) rules.push((a) => letters(a).endsWith(end[1])); const cont = p.match(/contain(?:s|ing)? (?:the letters? |an? )?["'“]([a-z]{1,6})["'”]/) || p.match(/contain(?:s|ing)? the letters? ([a-z]{1,6})\b/); if (cont) rules.push((a) => letters(a).includes(cont[1])); const len = p.match(/\b(\d{1,2})[- ]letters?\b/); if (len) rules.push((a) => letters(a).length === +len[1]); const words = { one: 1, two: 2, three: 3, four: 4 }; const wc = p.match(/\b(one|two|three|four|single)[- ]word\b/); if (wc) rules.push((a) => a.trim().split(/\s+/).length === (wc[1] === 'single' ? 1 : words[wc[1]])); return rules; } const fitsRules = (prompt, answer) => promptRules(prompt).every((r) => r(answer)); // ---------- Asking the AI ---------- function addSuggestion(prompt, answer, rarity) { answer = answer.replace(/^[\d.)\-\s*•]+/, '').replace(/\*\*/g, '').replace(/\s*\(.*?\)\s*/g, ' ').trim(); if (!answer || answer.length > 60) return false; const entry = entryFor(prompt); if (findAnswer(entry, answer)) return false; const a = { answer, rarity: Math.max(1, Math.min(5, rarity | 0 || 3)) }; if (!fitsRules(prompt, answer)) a.doubt = 'breaks the prompt rules'; entry.answers.push(a); return !a.doubt; } // Second pass: ask the AI to strictly judge its own unconfirmed answers. function verify(prompt) { const entry = store[norm(prompt)]; if (!entry) return; const todo = entry.answers.filter((a) => !a.tier && !a.miss && !a.doubt && !a.checked && !a.mine); if (!todo.length) return; verifying = true; if (prompt === lastPrompt) render(prompt); llmRequest({ system: 'You are a strict fact-checker for a trivia game. For each candidate, decide whether it is a real thing that ' + 'correctly and fully answers the prompt (every condition) and would appear in Wikipedia or a dictionary under that name. ' + 'Reject anything invented, misspelled, or only loosely related. Reply ONLY with JSON: {"valid":[...],"invalid":[...]} using the candidates exactly as given.', user: 'Prompt: ' + prompt + '\nCandidates:\n' + todo.map((a) => a.answer).join('\n'), stream: false, temperature: 0, onDone(err, text) { if (!err) { try { const out = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)); const bad = new Set((out.invalid || []).map(norm)); const good = new Set((out.valid || []).map(norm)); for (const a of todo) { if (bad.has(norm(a.answer))) a.doubt = 'failed double-check'; else if (good.has(norm(a.answer))) a.checked = true; } saveStore(); } catch (e) { /* leave unchecked */ } } verifying = false; if (prompt === lastPrompt) render(prompt); }, }); } function ask(prompt, wantMore) { const k = norm(prompt); if (store[k] && store[k].answers.length && !wantMore) return render(prompt); if (pending) return; const key = getKey(false); if (!key && !prov().local) return render(prompt, `No ${prov().label} API key — use ⚙ → API key.`, true); const entry = store[k]; const known = entry ? entry.answers.filter((a) => !a.miss).map((a) => a.answer) : []; const misses = entry ? entry.answers.filter((a) => a.miss).map((a) => a.answer) : []; const common = entry ? entry.answers.filter((a) => a.tier === 'plankton' || a.tier === 'tooclever').map((a) => a.answer) : []; const hits = entry ? entry.answers.filter((a) => a.tier && TIERS[a.tier].score >= 60) .map((a) => `${a.canonical || a.answer} (${TIERS[a.tier].name.toLowerCase()})`) : []; const doubts = entry ? entry.answers.filter((a) => a.doubt).map((a) => a.answer) : []; const rules = promptRules(prompt).length; const exclude = [...new Set([...known, ...partySaid, ...doubts])]; const depthRule = { safe: 'Prefer answers of rarity 2–3: uncommon but well known enough to appear on Wikipedia list pages.', balanced: 'Aim mostly for rarity 3–4: genuinely uncommon, but each must have its own Wikipedia article or dictionary entry.', deep: 'Go for rarity 4–5, but every answer must still be real and verifiable on Wikipedia or in a dictionary.', }[settings.depth] || ''; const system = 'You help a player in a trivia game. Each prompt has a curated answer set built from Wikipedia, dictionaries and encyclopedias; ' + 'rarer valid answers score more, but answers not in the set score nothing. ' + 'Read the prompt literally: every answer must match ALL of its conditions exactly (type, category, letters, era, etc.). ' + 'Never invent or guess names. If unsure an answer is real and fits, leave it out. ' + depthRule + ' ' + `List ${settings.count} answers, most obscure first. ` + 'Output ONLY lines in the form: answer | rarity (rarity 1–5, 5 = very obscure, 1 = obvious). ' + 'No numbering, no headings, no explanations. Use the standard English name exactly as Wikipedia titles it, without parentheses or qualifiers. ' + 'Rarity means how few players would think of typing it, not how hard the fact is. ' + 'Avoid "famously obscure" picks that everyone reaches for when trying to be clever (the game scores those as common).' + (settings.extraInstructions.trim() ? '\nPlayer preferences: ' + settings.extraInstructions.trim() : ''); let user = 'Prompt: ' + prompt; if (rules) user += '\nThis prompt has spelling/letter rules: check every answer letter by letter before listing it.'; if (exclude.length) user += '\nDo NOT repeat any of these: ' + exclude.join(', '); if (hits.length) user += '\nThese scored well, so similar depth works and spellings like these are accepted: ' + hits.join(', '); if (misses.length) user += '\nThese were NOT accepted by the game, avoid similar: ' + misses.join(', '); if (common.length) user += '\nThese turned out too common, go deeper than them: ' + common.join(', '); pending = true; lastLatency = null; const t0 = Date.now(); render(prompt); let content = '', consumed = 0, added = 0; const consumeLines = (final) => { const lines = content.split('\n'); const upto = final ? lines.length : lines.length - 1; let changed = false; for (; consumed < upto; consumed++) { const m = lines[consumed].match(/^\s*(.+?)\s*\|\s*([1-5])/); if (m && addSuggestion(prompt, m[1], +m[2])) { if (!added) lastLatency = ((Date.now() - t0) / 1000).toFixed(1); added++; changed = true; } } if (changed) { saveStore(); if (prompt === lastPrompt) render(prompt); } }; llmRequest({ system, user, stream: true, temperature: settings.temperature, onDelta(t) { content += t; consumeLines(false); }, onDone(err, full) { pending = false; if (full && full.length > content.length) content = full; // non-streaming fallback let msg = err || '', isErr = !!err; if (!err) { consumeLines(true); if (!added) { msg = 'No new answers this time — try again.'; isErr = true; } } if (store[k]) store[k].at = Date.now(); saveStore(); updateSavedCount(); if (prompt === lastPrompt) render(prompt, msg, isErr); if (!isErr && settings.verify) verify(prompt); }, }); } // ---------- Learning real results ---------- const tierFromStyle = (el, prop) => { const m = (el.style.getPropertyValue(prop) || '').match(/--tier-([a-z]+)/); return m && TIERS[m[1]] ? m[1] : null; }; const quoted = (s) => { const m = String(s).match(/“([^”]+)”/); return m ? m[1].trim() : null; }; function record(prompt, typed, canonical, tier, miss) { if (!prompt) return; const entry = entryFor(prompt); let a = (typed && findAnswer(entry, typed)) || (canonical && findAnswer(entry, canonical)); if (!a) { const text = typed || canonical; if (!text) return; a = { answer: text, rarity: 0, mine: true }; entry.answers.push(a); } if (miss) { a.miss = true; delete a.tier; } else { a.tier = tier; delete a.miss; delete a.doubt; if (canonical && norm(canonical) !== norm(a.answer)) a.canonical = canonical; } saveStore(); updateSavedCount(); } function checkResults() { let changed = false; // Daily / unlimited / packs: result card after each guess. document.querySelectorAll('.ds-landed').forEach((el) => { if (handled.has(el)) return; handled.add(el); // Only trust cards that follow a guess made this session (reloads redraw old cards). if (!lastSubmitted) return; const ans = quoted(el.querySelector('.tans')?.textContent || ''); roundDoneFor = lastPrompt; // a result card means this round is over if (el.classList.contains('miss')) { record(lastPrompt, lastSubmitted, null, null, true); } else { const tier = tierFromStyle(el, '--tc'); if (tier) record(lastPrompt, lastSubmitted, ans, tier, false); } lastSubmitted = null; changed = true; }); // Rejected guesses: “X”: . Repeats ("already said") aren't misses. document.querySelectorAll('.ds-reject').forEach((el) => { if (handled.has(el)) return; handled.add(el); const txt = el.textContent; if (/already said|signal lost/i.test(txt)) return; const guess = quoted(txt) || lastSubmitted; if (guess) { record(lastPrompt, guess, null, null, true); changed = true; } }); // Puffer Party: accepted answers show up as speech bubbles with the "ok" class and the tier colour. document.querySelectorAll('.pf-say.ok').forEach((el) => { const t = norm(el.textContent); if (!t) return; if (!partySaid.has(t)) { partySaid.add(t); changed = true; } const tier = tierFromStyle(el, '--cc'); const seenKey = t + '|' + tier; if (tier && el.dataset.krSeen !== seenKey) { el.dataset.krSeen = seenKey; record(lastPrompt, t, null, tier, false); changed = true; } }); if (changed && lastPrompt) render(lastPrompt); } // Remember what was in the box when the player pressed Enter / Send. document.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target && e.target.matches && e.target.matches('input.ds-input')) { lastSubmitted = e.target.value.trim() || lastSubmitted; } }, true); document.addEventListener('submit', () => { const v = gameInput()?.value.trim(); if (v) lastSubmitted = v; }, true); // ---------- Keyboard shortcuts ---------- document.addEventListener('keydown', (e) => { const inGame = e.target && e.target.matches && e.target.matches('input.ds-input'); if (e.key === 'Tab' && inGame && visible.length && !e.ctrlKey && !e.altKey) { e.preventDefault(); cursor = e.shiftKey ? (cursor <= 0 ? visible.length - 1 : cursor - 1) : (cursor + 1) % visible.length; fillInput(visible[cursor].answer, false); render(lastPrompt); return; } if (!e.altKey || e.ctrlKey || e.metaKey) return; const k = e.key.toLowerCase(); if (/^[1-9]$/.test(e.key) && visible[+e.key - 1]) { e.preventDefault(); cursor = +e.key - 1; fillInput(visible[cursor].answer, settings.autoSubmit); render(lastPrompt); } else if (k === 'r' && lastPrompt) { e.preventDefault(); ask(lastPrompt, true); } else if (k === 'a' && lastPrompt) { e.preventDefault(); ask(lastPrompt, false); } else if (k === 'h') { e.preventDefault(); panel.classList.toggle('hidden'); } }, true); // ---------- Auto-answer (solo modes only) ---------- // Puffer Party is live against real people, so auto-answer never runs there. const inParty = () => location.pathname.startsWith('/party'); const autoEl = $('.auto'); let roundDoneFor = null; const auto = { prompt: null, fireAt: 0, tried: new Set(), askedMore: false }; setInterval(() => { if (!settings.autoAnswer) { autoEl.textContent = ''; return; } if (inParty()) { autoEl.textContent = 'auto-answer is off in Puffer Party'; return; } const input = gameInput(); if (!lastPrompt || !input || input.disabled || roundDoneFor === lastPrompt) { autoEl.textContent = 'auto-answer: waiting for a round'; auto.prompt = null; return; } const now = Date.now(); if (auto.prompt !== lastPrompt) { auto.prompt = lastPrompt; auto.tried = new Set(); auto.askedMore = false; auto.fireAt = now + (settings.autoDelay + Math.random() * settings.autoJitter) * 1000; } const next = visible.find((a) => !a.miss && !auto.tried.has(norm(a.answer))); if (now < auto.fireAt) { autoEl.textContent = `auto-answer in ${((auto.fireAt - now) / 1000).toFixed(1)}s` + (next ? ` → ${next.answer}` : ''); return; } if (!next) { if (pending || verifying) { autoEl.textContent = 'auto-answer: waiting for suggestions…'; return; } if (!auto.askedMore) { auto.askedMore = true; ask(lastPrompt, true); } autoEl.textContent = 'auto-answer: out of suggestions, asking for more…'; auto.fireAt = now + 1000; return; } auto.tried.add(norm(next.answer)); auto.askedMore = false; autoEl.textContent = `auto-answer: trying ${next.answer}`; fillInput(next.answer, true); auto.fireAt = now + settings.autoRetry * 1000; }, 100); // ---------- Watch the page ---------- function checkPrompt() { const prompts = [...document.querySelectorAll('.ds-card .prompt')].filter( (el) => !el.closest('.pf-newcat') && !el.closest('.gone') ); const el = prompts[prompts.length - 1]; const text = el && el.textContent.trim(); if (!text || text === lastPrompt) return; lastPrompt = text; cursor = -1; lastLatency = null; partySaid.clear(); // new category → old party answers no longer matter const entry = store[norm(text)]; if ((entry && entry.answers.length) || settings.autoAsk) ask(text, false); else render(text, 'press ▶ to ask the AI'); } let queued = false; new MutationObserver(() => { // batch bursts of DOM changes into one check per frame if (queued) return; queued = true; requestAnimationFrame(() => { queued = false; checkPrompt(); checkResults(); }); }).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style'] }); checkPrompt(); })();