openvibers/openvibe.live Commits
Arena: one story = one subject — overlapping discoveries and submissions fold into threads; first-name aliases
Changes
@@@ -34,6 +34,7 @@ No voting anywhere. The board is a list of **subjects** the community is actuall- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.++- **One story = one subject** — discovery hands the model the open subjects (ids, keywords, threads) and it returns `merge_into_id` for anything that is the same drama; on top of that `mergeCandidate` folds a new subject into an existing one when they share a roster name, ≥ 2 keywords / a phrase keyword, or ≥ 50 % of their words. Folding = the would-be subject and its threads become threads of the existing subject (threads that are themselves another open subject are skipped) and its keywords widen the match. User submissions fold the same way (the reply says so). Lore rewrites never add a thread that is another open subject.- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.@@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/staff-console.js?v=1" defer></script><script src="/js/dashboard-moderation.js?v=5" defer></script><script src="/js/ai-viewers.js?v=2" defer></script>--<script src="/js/arena.js?v=14" defer></script>++<script src="/js/arena.js?v=15" defer></script><script src="/js/powerchat.js?v=10" defer></script><script src="/js/pastes.js?v=15" defer></script><script src="/js/draggable-fab.js?v=2" defer></script>@@@ -493,7 +493,7 @@ function _aTopicComposer() {const text = box.querySelector('#arena-composer-text').value.trim();if (!text) return;const btn = box.querySelector('#arena-composer-go'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Cooking…';-- try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast(`📌 On the board: ${r.topic.headline || r.topic.text}`, 'success'); navigate(`/arena/topic/${r.topic.id}`); }++ try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast(r.folded ? `📌 Already a thing — folded into “${r.topic.headline || r.topic.text}” as a thread` : `📌 On the board: ${r.topic.headline || r.topic.text}`, 'success'); navigate(`/arena/topic/${r.topic.id}`); }catch (err) { _aToast(err?.message || 'Failed', 'error'); btn.disabled = false; btn.textContent = 'Put it up'; }});document.body.appendChild(box);@@@ -62,6 +62,7 @@ function handle(chat, ws, client, cmd, parts) {if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');const onRoster = !!arena.loadRoster().byId[client.user.id];const t = await board.submitTopic({ text, userId: client.user.id, ip: client.ip, creatorName: who, onRoster });++ if (t.folded) { reply(`📌 That's already on the board — folded into “${t.text}” as a new thread → ${base()}/arena/topic/${t.id}`); return; }board.buildLore(t.id, { force: true }).catch(() => {});reply(`📌 On the board as “${t.text}”${t.headline ? ` — ${t.headline}` : ''} → ${base()}/arena/topic/${t.id}`);room(`📌 ${who} put a subject on the Arena board: “${t.text}” — say it on mic or keep it going in chat. ${base()}/arena/topic/${t.id}`);@@@ -311,6 +311,13 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }}++ const mc = mergeCandidate(refined?.subject || raw, refined?.keywords || keywordsFromText(raw));++ if (mc) {++ const r = foldInto(mc.id, { subject: refined?.subject || raw, keywords: refined?.keywords || [], hint: refined?.hint || null, threads: refined?.threads || [] });++ const existing = db.get('SELECT * FROM arena_topics WHERE id = ?', [mc.id]);++ console.log(`[Arena] ${creatorName}'s "${raw}" folded into subject #${mc.id} (${mc.why})`);++ return { ...existing, folded: true, threads_added: r.threads_added };++ }const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, threads: refined?.threads || null, submittedText: raw });backfillMoments([t]);try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); require('./progress').event(`user:${userId}`, 'subject', `Started “${t.headline || t.text}”`, { url: `/arena/topic/${t.id}` }); } catch { /* */ }@@@ -344,6 +351,49 @@ function addMoment(topicId, { kind, source, userId = null, username = null, strereturn m;}++/**++ * Is this (subject text, keywords) really an existing open subject? Same story = fold in as a thread.++ * • shares a distinctive keyword that is a roster name (a subject about goosely already exists)++ * • shares ≥ 2 keywords, or ≥ 1 multi-word keyword++ * • significant words of the subject text overlap ≥ 50 % (jaccard) with an open subject's++ */++function mergeCandidate(text, keywords, { exclude = null } = {}) {++ const kws = normalizeKeywords(keywords || []);++ const words = new Set(keywordsFromText(text));++ // Roster people named in the text/keywords — a subject that already stars them is the same story.++ const namedVariants = new Set();++ try {++ const roster = arena().loadRoster(); const nm = require('./names');++ const entries = nm.rosterEntries(roster);++ const hay = `${text} ${kws.join(' ')}`;++ for (const h of nm.findMentions(hay, entries, { fuzzy: false })) { for (const v of nm.variants(roster.byId[h.userId].user.username)) namedVariants.add(v); const first = nm.splitHandle(roster.byId[h.userId].user.username)[0]; if (first) namedVariants.add(first); }++ } catch { /* */ }++ for (const m of openTopicMatchers()) {++ if (exclude && m.id === exclude) continue;++ const shared = m.keywords.filter(k => kws.includes(k));++ const star = m.keywords.find(k => namedVariants.has(k));++ if (star) return { id: m.id, why: `about ${star}` };++ if (shared.length >= 2 || shared.some(k => k.includes(' '))) return { id: m.id, why: `shares ${shared.join(', ')}` };++ const t = db.get('SELECT text FROM arena_topics WHERE id = ?', [m.id]);++ const other = new Set(keywordsFromText(t?.text || ''));++ const inter = [...words].filter(w => other.has(w)).length;++ const union = new Set([...words, ...other]).size;++ if (words.size && other.size && inter / union >= 0.5) return { id: m.id, why: 'same words' };++ }++ return null;++}++/** Fold a would-be subject into an existing one as a thread (its keywords widen the subject's). */++function foldInto(existingId, { subject, keywords, hint, threads }) {++ const list = [{ name: subject, keywords: keywords || [], hint: hint || null }, ...(Array.isArray(threads) ? threads : [])];++ // skip threads that are really another open subject++ const keep = list.filter(t => { const mc = mergeCandidate(t.name, t.keywords, { exclude: existingId }); return !mc; });++ const added = upsertThreads(existingId, keep);++ const merged = normalizeKeywords([...(parseJson(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [existingId])?.keywords_json, []) || []), ...normalizeKeywords(keywords || []), ...keep.flatMap(t => normalizeKeywords(t.keywords || []))]);++ db.run('UPDATE arena_topics SET keywords_json = ?, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [JSON.stringify(merged), existingId]);++ invalidateMatchers();++ return { id: existingId, threads_added: added };++}++/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */let _threadCache = { at: 0, byTopic: new Map() };function threadsOf(topicId) {@@@ -520,7 +570,8 @@ function scanChat() {const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },-- topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'keywords', 'threads', 'source', 'hint'], properties: {++ topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['merge_into_id', 'subject', 'headline', 'tagline', 'keywords', 'threads', 'source', 'hint'], properties: {++ merge_into_id: { type: 'integer', description: 'if this is really one of the subjects already on the board (same story, same people, same drama), that subject\'s id — its threads get added there instead of making a duplicate. 0 for a genuinely new subject.' },subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },@@@ -531,7 +582,7 @@ const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', addi} } },bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },} } };--const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. Do not repeat subjects already on the board (match by meaning — if it fits an existing subject, skip it, the lore rewrite adds threads there). For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;++const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. ONE story = ONE subject: if what people are saying is the same drama / the same people as a subject already on the board (listed with ids), set merge_into_id to that id and give the NEW threads only — never create a second subject about the same thing ("goosely drama" when "cat enema drama" already stars goosely is a duplicate). Never make a subject about the Arena board itself. For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;function discoverInput() {const out = { at: new Date().toISOString() };@@@ -540,7 +591,7 @@ function discoverInput() {try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }try { const roster = arena().loadRoster(); out.roster_usernames = roster.order.map(id => roster.byId[id].user.username); } catch { out.roster_usernames = []; }-- out.already_on_board = db.all(`SELECT text, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => `${r.text} [${(parseJson(r.keywords_json, []) || []).join(', ')}]`);++ out.already_on_board = db.all(`SELECT id, text, headline, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => ({ id: r.id, subject: r.text, headline: r.headline, keywords: parseJson(r.keywords_json, []) || [], threads: db.all('SELECT name FROM arena_topic_threads WHERE topic_id = ?', [r.id]).map(x => x.name) }));return out;}@@@ -581,10 +632,14 @@ async function discoverTopics({ force = false } = {}) {} else {topics = heuristicDiscover(input);}-- let made = 0;++ let made = 0, folded = 0;const created = [];for (const t of topics.slice(0, 4)) {-- try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++; } catch { /* dup */ }++ try {++ const target = (t.merge_into_id && db.get(`SELECT id FROM arena_topics WHERE id = ? AND status = 'open'`, [t.merge_into_id])) ? { id: t.merge_into_id, why: 'model said so' } : mergeCandidate(t.subject, t.keywords);++ if (target) { const r = foldInto(target.id, t); folded++; console.log(`[Arena] "${t.subject}" folded into subject #${target.id} (${target.why}; +${r.threads_added} thread(s))`); continue; }++ created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++;++ } catch (e) { console.warn('[Arena] discover create:', e.message); }}for (const b of bounties.slice(0, 1)) {try {@@@ -594,8 +649,8 @@ async function discoverTopics({ force = false } = {}) {} catch { /* dup */ }}if (created.length) backfillMoments(created, { windowMin: scanWindowMin() * 4 });-- if (made) console.log(`[Arena] discovered ${made} subject(s) from the last ${scanWindowMin()} min`);-- return { made, pulse: _pulse.text };++ if (made || folded) console.log(`[Arena] discovered ${made} subject(s), folded ${folded} into existing ones (last ${scanWindowMin()} min)`);++ return { made, folded, pulse: _pulse.text };}/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */@@@ -684,7 +739,7 @@ async function buildLore(topicId, { force = false } = {}) {const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',[clip(out.lore, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);-- try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }++ try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads.filter(t => !mergeCandidate(t.name, t.keywords, { exclude: topicId }))); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }invalidateMatchers();try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }return { lore: out.lore, headline: out.headline, tagline: out.tagline };@@@ -775,7 +830,7 @@ function levelsLeaderboard(limit = 10) {function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }module.exports = {-- ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,++ ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, mergeCandidate, foldInto, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,computeHeat, resolveExpired, openBountyOn, recordBountyHit, HOT_THRESHOLD, KIND_TTL_HOURS, XP_PER_LEVEL, XP_JOIN, XP_HYPE, TOPIC_TTL_HOURS, SCAN_WINDOW_MIN, USER_TOPIC_COOLDOWN_HOURS,@@@ -49,15 +49,7 @@ const names = require('./names');let _aliasCache = { at: 0, list: [] };function aliases(roster) {if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;-- const list = [];-- for (const id of roster.order) {-- const f = roster.byId[id];-- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);-- const spoken = Array.isArray(persona?.spoken_as) ? persona.spoken_as : [];-- list.push(...names.aliasEntries(id, [f.user.username, f.user.display_name, persona?.fighter_name, ...spoken]));-- }-- // Longer names first so "goosely" beats "goose" style prefixes.-- list.sort((a, b) => b.name.length - a.name.length);++ const list = names.rosterEntries(roster, (id) => { const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json); return [persona?.fighter_name, ...(Array.isArray(persona?.spoken_as) ? persona.spoken_as : [])]; });_aliasCache = { at: Date.now(), list };return list;}@@@ -162,4 +162,24 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);}--module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, findMentions, variantRegex, COMMON };++/**++ * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's++ * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes++ * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.++ * `extra(id)` may add persona names / spoken_as forms per fighter.++ */++function rosterEntries(roster, extra = null) {++ const firsts = new Map();++ for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }++ const list = [];++ for (const id of roster.order) {++ const u = roster.byId[id].user;++ const names = [u.username, u.display_name, ...(extra ? (extra(id) || []) : [])];++ const first = splitHandle(u.username)[0];++ if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) names.push(first);++ list.push(...aliasEntries(id, names));++ }++ return list.sort((a, b) => b.name.length - a.name.length);++}++++module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };@@@ -106,8 +106,8 @@ router.post('/board/topics', requireAuth, async (req, res) => {try {const onRoster = !!arena.loadRoster().byId[req.user.id];const t = await board.submitTopic({ text: req.body?.text, userId: req.user.id, ip: req.ip, creatorName: req.user.display_name || req.user.username, onRoster });-- board.buildLore(t.id, { force: true }).catch(() => {});-- res.json({ ok: true, topic: board.topicDetail(t.id) });++ if (!t.folded) board.buildLore(t.id, { force: true }).catch(() => {});++ res.json({ ok: true, folded: !!t.folded, topic: board.topicDetail(t.id) });} catch (err) { res.status(400).json({ error: err.message }); }});router.post('/board/bounty', requireAuth, (req, res) => {@@@ -142,7 +142,23 @@ board.ensureTables(); beef.ensureTables();const curry = db.get(`SELECT * FROM arena_topics WHERE keywords_json LIKE '%curry%'`);assert.ok(curry && curry.created_by === 'community');assert.ok(board.topicDetail(curry.id).mentions.chat >= 4, 'backfilled with the lines that started it');-- console.log('✅ discovery from chat bursts + backfill');++ // Same story = same subject: overlapping discoveries and submissions fold in as threads.++ const mc = board.mergeCandidate('goosely drama', ['goosely', 'mic on']);++ assert.strictEqual(mc, null, 'nothing about goosely yet → new subject is fine');++ const gz = board.createTopic({ text: 'Grizzly bear drama', createdBy: 'community', creatorName: 'chat', keywords: ['grizzly bear', 'alts'], threads: [{ name: 'the alt accounts', keywords: ['alts'] }] });++ assert.ok(board.mergeCandidate('grizzly vs everyone', ['grizzly bear', 'salty']), 'shares a roster name → same subject');++ assert.ok(board.mergeCandidate('The alt account thing', ['alts', 'bots']) === null, 'one plain shared keyword is not enough');++ assert.ok(board.mergeCandidate('Grizzly bear alt accounts', ['grizzly', 'alts', 'accounts']), 'two shared keywords → same subject');++ const fold = board.foldInto(gz.id, { subject: 'grizzly vs everyone', keywords: ['salty'], hint: 'he is', threads: [{ name: 'the salty era', keywords: ['salty'] }, { name: 'the baby voice', keywords: ['baby voice'] }] });++ assert.strictEqual(fold.id, gz.id);++ const names2 = board.threadsOf(gz.id).map(x => x.name);++ assert.ok(names2.includes('grizzly vs everyone') && names2.includes('the salty era'), `folded subject + its threads: ${names2}`);++ assert.ok(!names2.includes('the baby voice'), 'a thread that is really another open subject is not added');++ assert.ok(JSON.parse(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [gz.id]).keywords_json).includes('salty'), 'keywords widen');++ const sub2 = await board.submitTopic({ text: 'grizzly is salty again', userId: u2, ip: '8.8.8.8', creatorName: 'Grizzly', onRoster: true });++ assert.strictEqual(sub2.folded, true); assert.strictEqual(sub2.id, gz.id, 'a user submission about an existing story folds in');++ db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [gz.id]);++ console.log('✅ discovery from chat bursts + backfill, duplicates fold into threads');// Bounty: a subject with a target; expires into the archive with a result.const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', kind: 'bounty', targetUserId: u3, headline: 'WANTED' });
@@ -34,6 +34,7 @@ No voting anywhere. The board is a list of **subjects** the community is actuall
@@ -34,6 +34,7 @@ No voting anywhere. The board is a list of **subjects** the community is actuall
- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.
- **Discovery** — every 5 min (only when ≥ 8 new lines exist, so a dead room costs nothing; window `arena_discover_window_min`, default 30) the AI reads global + stream chat and live transcripts and returns the **umbrella subjects** people are really on about (1–4 words: a person, a group, a joke, a drama…) each with 2–4 **threads** — the specific angles being argued (name + keywords + hint) — a short headline and tagline in the community's own voice, the keywords/slang people use, and optionally one **bounty** on a streamer the community keeps naming. Voice rules: write like a regular of this chat (its casing, slang, rhythm), no tabloid adjective soup, no invented words; the prompt gets recent chat lines, the global chat AI overview and the chat AI's profiles of the people involved. Without AI, a word ≥ 3 people said ≥ 4 times becomes a subject. New subjects are **backfilled** with every matching line from the last 2 h so they never start empty.
- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.
- **Moments** — every minute new `chat_messages` are scanned; any line matching a subject's keywords becomes a chat **moment** on it and is filed into the first thread whose keywords match (`thread_id`). The listener does the same for transcript lines (an on-mic moment with a VOD deep link, one per stream per 45 s) and **auto-joins** the fighter to the subject. Judged chunks (≥ 20 words, AI quality 0–10) are moments with a score → XP (quality × 0.8) → Trash Level.
+- **One story = one subject** — discovery hands the model the open subjects (ids, keywords, threads) and it returns `merge_into_id` for anything that is the same drama; on top of that `mergeCandidate` folds a new subject into an existing one when they share a roster name, ≥ 2 keywords / a phrase keyword, or ≥ 50 % of their words. Folding = the would-be subject and its threads become threads of the existing subject (threads that are themselves another open subject are skipped) and its keywords widen the match. User submissions fold the same way (the reply says so). Lore rewrites never add a thread that is another open subject.
- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.
- **Nobody joins anything** — the ears detect who is on what: a fighter saying a subject on mic is auto-filed as a member; there are no join/leave endpoints or buttons.
- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).
- **Lore** — when a subject has ≥ 3 new moments (and ≥ 8 min since the last rewrite) the AI rewrites its short (≤ 450 chars) story so far and returns the updated threads (existing kept, new angles added, ≤ 6 per subject; unfiled moments are re-filed): who started it, who said what (verbatim, with usernames), who is on which end, escalations. Templated when AI is off. Lore stays in the archive after the subject cools off (36 h idle).
- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.
- **User subjects** — signed-in only, **one per person and one per IP per 24 h**. The AI rewrites what was typed into a subject + headline + keywords (`submitTopic`, one small call) and backfills moments. `!topic <text>` in chat does the same.
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5107,7 +5107,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
-<script src="/js/arena.js?v=14" defer></script>
+<script src="/js/arena.js?v=15" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@ -493,7 +493,7 @@ function _aTopicComposer() {
@@ -493,7 +493,7 @@ function _aTopicComposer() {
const text = box.querySelector('#arena-composer-text').value.trim();
const text = box.querySelector('#arena-composer-text').value.trim();
if (!text) return;
if (!text) return;
const btn = box.querySelector('#arena-composer-go'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Cooking…';
const btn = box.querySelector('#arena-composer-go'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Cooking…';
- try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast(`📌 On the board: ${r.topic.headline || r.topic.text}`, 'success'); navigate(`/arena/topic/${r.topic.id}`); }
+ try { const r = await api('/arena/board/topics', { method: 'POST', body: { text } }); close(); _aToast(r.folded ? `📌 Already a thing — folded into “${r.topic.headline || r.topic.text}” as a thread` : `📌 On the board: ${r.topic.headline || r.topic.text}`, 'success'); navigate(`/arena/topic/${r.topic.id}`); }
catch (err) { _aToast(err?.message || 'Failed', 'error'); btn.disabled = false; btn.textContent = 'Put it up'; }
catch (err) { _aToast(err?.message || 'Failed', 'error'); btn.disabled = false; btn.textContent = 'Put it up'; }
});
});
document.body.appendChild(box);
document.body.appendChild(box);
@@ -62,6 +62,7 @@ function handle(chat, ws, client, cmd, parts) {
@@ -62,6 +62,7 @@ function handle(chat, ws, client, cmd, parts) {
if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');
if (!text) return reply('Usage: !topic <something the streamers should talk shit about>');
const onRoster = !!arena.loadRoster().byId[client.user.id];
const onRoster = !!arena.loadRoster().byId[client.user.id];
const t = await board.submitTopic({ text, userId: client.user.id, ip: client.ip, creatorName: who, onRoster });
const t = await board.submitTopic({ text, userId: client.user.id, ip: client.ip, creatorName: who, onRoster });
+ if (t.folded) { reply(`📌 That's already on the board — folded into “${t.text}” as a new thread → ${base()}/arena/topic/${t.id}`); return; }
board.buildLore(t.id, { force: true }).catch(() => {});
board.buildLore(t.id, { force: true }).catch(() => {});
reply(`📌 On the board as “${t.text}”${t.headline ? ` — ${t.headline}` : ''} → ${base()}/arena/topic/${t.id}`);
reply(`📌 On the board as “${t.text}”${t.headline ? ` — ${t.headline}` : ''} → ${base()}/arena/topic/${t.id}`);
room(`📌 ${who} put a subject on the Arena board: “${t.text}” — say it on mic or keep it going in chat. ${base()}/arena/topic/${t.id}`);
room(`📌 ${who} put a subject on the Arena board: “${t.text}” — say it on mic or keep it going in chat. ${base()}/arena/topic/${t.id}`);
@@ -311,6 +311,13 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
@@ -311,6 +311,13 @@ async function submitTopic({ text, userId, ip, creatorName, onRoster = false })
if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }
if (r && r.json) { if (r.json.reject) throw new Error('That crosses the line (threats, minors, doxxing)'); refined = r.json; }
} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }
} catch (e) { if (/crosses the line/.test(e.message)) throw e; console.warn('[Arena] refine topic:', e.message); }
}
}
+ const mc = mergeCandidate(refined?.subject || raw, refined?.keywords || keywordsFromText(raw));
+ if (mc) {
+ const r = foldInto(mc.id, { subject: refined?.subject || raw, keywords: refined?.keywords || [], hint: refined?.hint || null, threads: refined?.threads || [] });
+ const existing = db.get('SELECT * FROM arena_topics WHERE id = ?', [mc.id]);
+ console.log(`[Arena] ${creatorName}'s "${raw}" folded into subject #${mc.id} (${mc.why})`);
+ return { ...existing, folded: true, threads_added: r.threads_added };
+ }
const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, threads: refined?.threads || null, submittedText: raw });
const t = createTopic({ text: refined?.subject || raw, hint: refined?.hint || null, createdBy: onRoster ? 'streamer' : 'viewer', creatorUserId: userId, creatorName, creatorIp: ip, headline: refined?.headline || null, tagline: refined?.tagline || `Put up by ${creatorName}`, sourceNote: creatorName, keywords: refined?.keywords || null, threads: refined?.threads || null, submittedText: raw });
backfillMoments([t]);
backfillMoments([t]);
try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); require('./progress').event(`user:${userId}`, 'subject', `Started “${t.headline || t.text}”`, { url: `/arena/topic/${t.id}` }); } catch { /* */ }
try { require('./chatters').onSubjectStarted(`user:${userId}`, t.id, { display: creatorName }); require('./progress').event(`user:${userId}`, 'subject', `Started “${t.headline || t.text}”`, { url: `/arena/topic/${t.id}` }); } catch { /* */ }
@@ -344,6 +351,49 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
@@ -344,6 +351,49 @@ function addMoment(topicId, { kind, source, userId = null, username = null, stre
return m;
return m;
}
}
+/**
+ * Is this (subject text, keywords) really an existing open subject? Same story = fold in as a thread.
+ * • shares a distinctive keyword that is a roster name (a subject about goosely already exists)
+ * • shares ≥ 2 keywords, or ≥ 1 multi-word keyword
+ * • significant words of the subject text overlap ≥ 50 % (jaccard) with an open subject's
+ */
+function mergeCandidate(text, keywords, { exclude = null } = {}) {
+ const kws = normalizeKeywords(keywords || []);
+ const words = new Set(keywordsFromText(text));
+ // Roster people named in the text/keywords — a subject that already stars them is the same story.
+ const namedVariants = new Set();
+ try {
+ const roster = arena().loadRoster(); const nm = require('./names');
+ const entries = nm.rosterEntries(roster);
+ const hay = `${text} ${kws.join(' ')}`;
+ for (const h of nm.findMentions(hay, entries, { fuzzy: false })) { for (const v of nm.variants(roster.byId[h.userId].user.username)) namedVariants.add(v); const first = nm.splitHandle(roster.byId[h.userId].user.username)[0]; if (first) namedVariants.add(first); }
+ } catch { /* */ }
+ for (const m of openTopicMatchers()) {
+ if (exclude && m.id === exclude) continue;
+ const shared = m.keywords.filter(k => kws.includes(k));
+ const star = m.keywords.find(k => namedVariants.has(k));
+ if (star) return { id: m.id, why: `about ${star}` };
+ if (shared.length >= 2 || shared.some(k => k.includes(' '))) return { id: m.id, why: `shares ${shared.join(', ')}` };
+ const t = db.get('SELECT text FROM arena_topics WHERE id = ?', [m.id]);
+ const other = new Set(keywordsFromText(t?.text || ''));
+ const inter = [...words].filter(w => other.has(w)).length;
+ const union = new Set([...words, ...other]).size;
+ if (words.size && other.size && inter / union >= 0.5) return { id: m.id, why: 'same words' };
+ }
+ return null;
+}
+/** Fold a would-be subject into an existing one as a thread (its keywords widen the subject's). */
+function foldInto(existingId, { subject, keywords, hint, threads }) {
+ const list = [{ name: subject, keywords: keywords || [], hint: hint || null }, ...(Array.isArray(threads) ? threads : [])];
+ // skip threads that are really another open subject
+ const keep = list.filter(t => { const mc = mergeCandidate(t.name, t.keywords, { exclude: existingId }); return !mc; });
+ const added = upsertThreads(existingId, keep);
+ const merged = normalizeKeywords([...(parseJson(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [existingId])?.keywords_json, []) || []), ...normalizeKeywords(keywords || []), ...keep.flatMap(t => normalizeKeywords(t.keywords || []))]);
+ db.run('UPDATE arena_topics SET keywords_json = ?, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?', [JSON.stringify(merged), existingId]);
+ invalidateMatchers();
+ return { id: existingId, threads_added: added };
+}
+
/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */
/** Which thread of a subject a line belongs to (first thread whose keywords match), or null. */
let _threadCache = { at: 0, byTopic: new Map() };
let _threadCache = { at: 0, byTopic: new Map() };
function threadsOf(topicId) {
function threadsOf(topicId) {
@@ -520,7 +570,8 @@ function scanChat() {
@@ -520,7 +570,8 @@ function scanChat() {
const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {
const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', additionalProperties: false, required: ['pulse', 'topics', 'bounties'], properties: {
pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },
pulse: { type: 'string', description: 'One sentence ≤ 140 chars: what the community is on about right now, dumb hype-caster voice, name names, funny' },
- topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['subject', 'headline', 'tagline', 'keywords', 'threads', 'source', 'hint'], properties: {
+ topics: { type: 'array', minItems: 0, maxItems: 4, items: { type: 'object', additionalProperties: false, required: ['merge_into_id', 'subject', 'headline', 'tagline', 'keywords', 'threads', 'source', 'hint'], properties: {
+ merge_into_id: { type: 'integer', description: 'if this is really one of the subjects already on the board (same story, same people, same drama), that subject\'s id — its threads get added there instead of making a duplicate. 0 for a genuinely new subject.' },
subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },
subject: { type: 'string', description: 'The UMBRELLA subject, 1–4 words, a noun: the thing, person, group, joke or drama — e.g. "Cat enema drama", "Pakistanis", "Goosely", "Tent streams". Vague on purpose; the detail lives in threads.' },
headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },
headline: { type: 'string', description: 'Tabloid headline ≤ 70 chars, inflammatory, stupid-funny' },
tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },
tagline: { type: 'string', description: '≤ 80 chars: where it stands — who started it, who is on which end' },
@@ -531,7 +582,7 @@ const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', addi
@@ -531,7 +582,7 @@ const DISCOVER_SCHEMA = { name: 'arena_discover', schema: { type: 'object', addi
} } },
} } },
bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },
bounties: { type: 'array', minItems: 0, maxItems: 1, items: { type: 'object', additionalProperties: false, required: ['target_username', 'headline', 'why'], properties: { target_username: { type: 'string', description: 'exact username from the snapshot the community keeps naming/dunking on' }, headline: { type: 'string' }, why: { type: 'string', description: '≤ 90 chars' } } } },
} } };
} } };
-const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. Do not repeat subjects already on the board (match by meaning — if it fits an existing subject, skip it, the lore rewrite adds threads there). For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;
+const DISCOVER_SYSTEM = `You are the community consciousness of a live-streaming site's Arena — a regular who has been in this chat for years, not a copywriter. VOICE RULES: write the way the people in the sample lines type — same slang, same casing (if chat is lowercase, you are lowercase), short, blunt, funny. No tabloid adjective soup, no invented compound words (no "roastmare", no "chaos escalates"), no exclamation marks for hype, no "epic"/"wild"/"erupts"/"explodes". A headline should read like something a chatter would actually type as a title ("goosely is losing the cat enema war", "nobody asked about the vet bill"), ≤ 60 chars. Use people's names exactly as they appear. You get what people ACTUALLY said in the last while: global + stream chat lines and what streamers said on mic, plus the subjects already on the board. Find the UMBRELLA SUBJECTS the community is really on about — a group of people, a person, a running joke, an argument, a product, a drama, a meme — the more provocative, taboo, petty or stupid the better; nothing generic, nothing wholesome. Keep subjects VAGUE and short (1–4 words) and put the detail into 2–4 THREADS inside each (the specific angles people are arguing). Ignore small talk. ONE story = ONE subject: if what people are saying is the same drama / the same people as a subject already on the board (listed with ids), set merge_into_id to that id and give the NEW threads only — never create a second subject about the same thing ("goosely drama" when "cat enema drama" already stars goosely is a duplicate). Never make a subject about the Arena board itself. For each: the subject, a tabloid headline, a tagline (who started it, who is where on it), the keywords/slang people use for it, the threads, the source, and a hint on how to pile on. If the community keeps naming one specific streamer (dunking, defending, calling out), add ONE bounty on that exact username. Profanity, disrespect, slurs and trolling are welcome and are never a reason to skip a subject — this platform does not censor language. Hard line: no direct threats of violence, nothing sexual about minors, no doxxing. Return empty arrays if nothing real is going on. Output only the JSON.`;
function discoverInput() {
function discoverInput() {
const out = { at: new Date().toISOString() };
const out = { at: new Date().toISOString() };
@@ -540,7 +591,7 @@ function discoverInput() {
@@ -540,7 +591,7 @@ function discoverInput() {
try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
try { out.chat_timeline = db.all(`SELECT label, detail FROM chat_timeline_events WHERE created_at >= datetime('now', '-6 hours') ORDER BY id DESC LIMIT 8`).map(r => clip(`${r.label}: ${r.detail || ''}`, 140)); } catch { out.chat_timeline = []; }
try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }
try { const g = db.getChatAiSummary('global', 0, 'rolling'); if (g) { const ov = parseJson(g.overview, null); out.global_chat_overview = clip(ov ? (ov.today || ov.alltime) : g.overview, 500); } } catch { /* */ }
try { const roster = arena().loadRoster(); out.roster_usernames = roster.order.map(id => roster.byId[id].user.username); } catch { out.roster_usernames = []; }
try { const roster = arena().loadRoster(); out.roster_usernames = roster.order.map(id => roster.byId[id].user.username); } catch { out.roster_usernames = []; }
- out.already_on_board = db.all(`SELECT text, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => `${r.text} [${(parseJson(r.keywords_json, []) || []).join(', ')}]`);
+ out.already_on_board = db.all(`SELECT id, text, headline, keywords_json FROM arena_topics WHERE status = 'open' ORDER BY id DESC LIMIT 20`).map(r => ({ id: r.id, subject: r.text, headline: r.headline, keywords: parseJson(r.keywords_json, []) || [], threads: db.all('SELECT name FROM arena_topic_threads WHERE topic_id = ?', [r.id]).map(x => x.name) }));
return out;
return out;
}
}
@@ -581,10 +632,14 @@ async function discoverTopics({ force = false } = {}) {
@@ -581,10 +632,14 @@ async function discoverTopics({ force = false } = {}) {
} else {
} else {
topics = heuristicDiscover(input);
topics = heuristicDiscover(input);
}
}
- let made = 0;
+ let made = 0, folded = 0;
const created = [];
const created = [];
for (const t of topics.slice(0, 4)) {
for (const t of topics.slice(0, 4)) {
- try { created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++; } catch { /* dup */ }
+ try {
+ const target = (t.merge_into_id && db.get(`SELECT id FROM arena_topics WHERE id = ? AND status = 'open'`, [t.merge_into_id])) ? { id: t.merge_into_id, why: 'model said so' } : mergeCandidate(t.subject, t.keywords);
+ if (target) { const r = foldInto(target.id, t); folded++; console.log(`[Arena] "${t.subject}" folded into subject #${target.id} (${target.why}; +${r.threads_added} thread(s))`); continue; }
+ created.push(createTopic({ text: t.subject, hint: t.hint, createdBy: 'community', creatorName: clip(t.source || 'the community', 40), headline: t.headline, tagline: t.tagline, sourceNote: t.source, keywords: t.keywords, threads: t.threads })); made++;
+ } catch (e) { console.warn('[Arena] discover create:', e.message); }
}
}
for (const b of bounties.slice(0, 1)) {
for (const b of bounties.slice(0, 1)) {
try {
try {
@@ -594,8 +649,8 @@ async function discoverTopics({ force = false } = {}) {
@@ -594,8 +649,8 @@ async function discoverTopics({ force = false } = {}) {
} catch { /* dup */ }
} catch { /* dup */ }
}
}
if (created.length) backfillMoments(created, { windowMin: scanWindowMin() * 4 });
if (created.length) backfillMoments(created, { windowMin: scanWindowMin() * 4 });
- if (made) console.log(`[Arena] discovered ${made} subject(s) from the last ${scanWindowMin()} min`);
- return { made, pulse: _pulse.text };
+ if (made || folded) console.log(`[Arena] discovered ${made} subject(s), folded ${folded} into existing ones (last ${scanWindowMin()} min)`);
+ return { made, folded, pulse: _pulse.text };
}
}
/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
/** Seed a fresh topic with the lines from the window that match it, so it never starts empty. */
@@ -684,7 +739,7 @@ async function buildLore(topicId, { force = false } = {}) {
@@ -684,7 +739,7 @@ async function buildLore(topicId, { force = false } = {}) {
const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);
const kws = normalizeKeywords([...(parseJson(topic.keywords_json, []) || []), ...(out.keywords || [])]);
db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',
db.run('UPDATE arena_topics SET lore = ?, headline = COALESCE(?, headline), tagline = COALESCE(?, tagline), keywords_json = ?, lore_updated_at = CURRENT_TIMESTAMP, lore_moment_count = ? WHERE id = ?',
[clip(out.lore, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);
[clip(out.lore, 600), out.headline ? clip(out.headline, 120) : null, out.tagline ? clip(out.tagline, 100) : null, JSON.stringify(kws), total, topicId]);
- try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }
+ try { if (Array.isArray(out.threads) && out.threads.length) { upsertThreads(topicId, out.threads.filter(t => !mergeCandidate(t.name, t.keywords, { exclude: topicId }))); const merged = normalizeKeywords([...kws, ...threadsOf(topicId).flatMap(t => parseJson(t.keywords_json, []) || [])]); db.run('UPDATE arena_topics SET keywords_json = ? WHERE id = ?', [JSON.stringify(merged), topicId]); } } catch (e) { console.warn('[Arena] threads:', e.message); }
invalidateMatchers();
invalidateMatchers();
try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }
try { require('./chatters').onLore(topic, out.lore, moments); } catch (e) { console.warn('[Arena] lore quotes xp:', e.message); }
return { lore: out.lore, headline: out.headline, tagline: out.tagline };
return { lore: out.lore, headline: out.headline, tagline: out.tagline };
@@ -775,7 +830,7 @@ function levelsLeaderboard(limit = 10) {
@@ -775,7 +830,7 @@ function levelsLeaderboard(limit = 10) {
function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
function yappersLeaderboard(limit = 10) { return require('./chatters').leaderboard(limit); }
module.exports = {
module.exports = {
- ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
+ ensureTables, createTopic, submitTopic, assertCanSubmit, joinTopic, leaveTopic, activeTopicFor, threadsOf, threadFor, upsertThreads, isBotChatter, mergeCandidate, foldInto, applyTopicJudgement, hypeTopic, addMoment, noteMicMention, matchTopics, keywordsFromText,
addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
addXp, levelRow, levelView, levelFor, recentXp, boardView, topicDetail, levelsLeaderboard, yappersLeaderboard, fighterBrief, archiveStale,
scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,
scanChat, discoverTopics, discoverInput, heuristicDiscover, backfillMoments, buildLore, loreSweep, templateLore, pulse, nameOf,
computeHeat, resolveExpired, openBountyOn, recordBountyHit, HOT_THRESHOLD, KIND_TTL_HOURS, XP_PER_LEVEL, XP_JOIN, XP_HYPE, TOPIC_TTL_HOURS, SCAN_WINDOW_MIN, USER_TOPIC_COOLDOWN_HOURS,
computeHeat, resolveExpired, openBountyOn, recordBountyHit, HOT_THRESHOLD, KIND_TTL_HOURS, XP_PER_LEVEL, XP_JOIN, XP_HYPE, TOPIC_TTL_HOURS, SCAN_WINDOW_MIN, USER_TOPIC_COOLDOWN_HOURS,
@@ -49,15 +49,7 @@ const names = require('./names');
@@ -49,15 +49,7 @@ const names = require('./names');
let _aliasCache = { at: 0, list: [] };
let _aliasCache = { at: 0, list: [] };
function aliases(roster) {
function aliases(roster) {
if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;
if (Date.now() - _aliasCache.at < 60 * 1000) return _aliasCache.list;
- const list = [];
- for (const id of roster.order) {
- const f = roster.byId[id];
- const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json);
- const spoken = Array.isArray(persona?.spoken_as) ? persona.spoken_as : [];
- list.push(...names.aliasEntries(id, [f.user.username, f.user.display_name, persona?.fighter_name, ...spoken]));
- }
- // Longer names first so "goosely" beats "goose" style prefixes.
- list.sort((a, b) => b.name.length - a.name.length);
+ const list = names.rosterEntries(roster, (id) => { const persona = parseJson(db.get('SELECT persona_json FROM arena_profiles WHERE user_id = ?', [id])?.persona_json); return [persona?.fighter_name, ...(Array.isArray(persona?.spoken_as) ? persona.spoken_as : [])]; });
_aliasCache = { at: Date.now(), list };
_aliasCache = { at: Date.now(), list };
return list;
return list;
}
}
@@ -162,4 +162,24 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
@@ -162,4 +162,24 @@ function findMentions(text, entries, { excludeUserId = null, fuzzy = true } = {}
return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);
return [...found.values()].sort((a, b) => b.rank - a.rank).map(({ rank, ...r }) => r);
}
}
-module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, findMentions, variantRegex, COMMON };
+/**
+ * Alias entries for a whole roster, roster-aware: besides every spoken form of each name, a handle's
+ * FIRST token ("grizzly" for grizzly_bear, "japanese" is NOT for JapaneseOldGuy — common word) becomes
+ * an alias when it is ≥ 5 letters, not a common word, and no other fighter starts with it.
+ * `extra(id)` may add persona names / spoken_as forms per fighter.
+ */
+function rosterEntries(roster, extra = null) {
+ const firsts = new Map();
+ for (const id of roster.order) { const f = splitHandle(roster.byId[id].user.username)[0]; if (f && f.length >= 5 && !COMMON.has(f) && !/^\d+$/.test(f)) firsts.set(f, (firsts.get(f) || 0) + 1); }
+ const list = [];
+ for (const id of roster.order) {
+ const u = roster.byId[id].user;
+ const names = [u.username, u.display_name, ...(extra ? (extra(id) || []) : [])];
+ const first = splitHandle(u.username)[0];
+ if (first && firsts.get(first) === 1 && !variants(u.username).includes(first)) names.push(first);
+ list.push(...aliasEntries(id, names));
+ }
+ return list.sort((a, b) => b.name.length - a.name.length);
+}
+
+module.exports = { variants, splitHandle, deleet, phonetic, levenshtein, aliasEntries, rosterEntries, findMentions, variantRegex, COMMON };
@@ -106,8 +106,8 @@ router.post('/board/topics', requireAuth, async (req, res) => {
@@ -106,8 +106,8 @@ router.post('/board/topics', requireAuth, async (req, res) => {
try {
try {
const onRoster = !!arena.loadRoster().byId[req.user.id];
const onRoster = !!arena.loadRoster().byId[req.user.id];
const t = await board.submitTopic({ text: req.body?.text, userId: req.user.id, ip: req.ip, creatorName: req.user.display_name || req.user.username, onRoster });
const t = await board.submitTopic({ text: req.body?.text, userId: req.user.id, ip: req.ip, creatorName: req.user.display_name || req.user.username, onRoster });
- board.buildLore(t.id, { force: true }).catch(() => {});
- res.json({ ok: true, topic: board.topicDetail(t.id) });
+ if (!t.folded) board.buildLore(t.id, { force: true }).catch(() => {});
+ res.json({ ok: true, folded: !!t.folded, topic: board.topicDetail(t.id) });
} catch (err) { res.status(400).json({ error: err.message }); }
} catch (err) { res.status(400).json({ error: err.message }); }
});
});
router.post('/board/bounty', requireAuth, (req, res) => {
router.post('/board/bounty', requireAuth, (req, res) => {
@@ -142,7 +142,23 @@ board.ensureTables(); beef.ensureTables();
@@ -142,7 +142,23 @@ board.ensureTables(); beef.ensureTables();
const curry = db.get(`SELECT * FROM arena_topics WHERE keywords_json LIKE '%curry%'`);
const curry = db.get(`SELECT * FROM arena_topics WHERE keywords_json LIKE '%curry%'`);
assert.ok(curry && curry.created_by === 'community');
assert.ok(curry && curry.created_by === 'community');
assert.ok(board.topicDetail(curry.id).mentions.chat >= 4, 'backfilled with the lines that started it');
assert.ok(board.topicDetail(curry.id).mentions.chat >= 4, 'backfilled with the lines that started it');
- console.log('✅ discovery from chat bursts + backfill');
+ // Same story = same subject: overlapping discoveries and submissions fold in as threads.
+ const mc = board.mergeCandidate('goosely drama', ['goosely', 'mic on']);
+ assert.strictEqual(mc, null, 'nothing about goosely yet → new subject is fine');
+ const gz = board.createTopic({ text: 'Grizzly bear drama', createdBy: 'community', creatorName: 'chat', keywords: ['grizzly bear', 'alts'], threads: [{ name: 'the alt accounts', keywords: ['alts'] }] });
+ assert.ok(board.mergeCandidate('grizzly vs everyone', ['grizzly bear', 'salty']), 'shares a roster name → same subject');
+ assert.ok(board.mergeCandidate('The alt account thing', ['alts', 'bots']) === null, 'one plain shared keyword is not enough');
+ assert.ok(board.mergeCandidate('Grizzly bear alt accounts', ['grizzly', 'alts', 'accounts']), 'two shared keywords → same subject');
+ const fold = board.foldInto(gz.id, { subject: 'grizzly vs everyone', keywords: ['salty'], hint: 'he is', threads: [{ name: 'the salty era', keywords: ['salty'] }, { name: 'the baby voice', keywords: ['baby voice'] }] });
+ assert.strictEqual(fold.id, gz.id);
+ const names2 = board.threadsOf(gz.id).map(x => x.name);
+ assert.ok(names2.includes('grizzly vs everyone') && names2.includes('the salty era'), `folded subject + its threads: ${names2}`);
+ assert.ok(!names2.includes('the baby voice'), 'a thread that is really another open subject is not added');
+ assert.ok(JSON.parse(db.get('SELECT keywords_json FROM arena_topics WHERE id = ?', [gz.id]).keywords_json).includes('salty'), 'keywords widen');
+ const sub2 = await board.submitTopic({ text: 'grizzly is salty again', userId: u2, ip: '8.8.8.8', creatorName: 'Grizzly', onRoster: true });
+ assert.strictEqual(sub2.folded, true); assert.strictEqual(sub2.id, gz.id, 'a user submission about an existing story folds in');
+ db.run(`UPDATE arena_topics SET status = 'archived' WHERE id = ?`, [gz.id]);
+ console.log('✅ discovery from chat bursts + backfill, duplicates fold into threads');
// Bounty: a subject with a target; expires into the archive with a result.
// Bounty: a subject with a target; expires into the archive with a result.
const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', kind: 'bounty', targetUserId: u3, headline: 'WANTED' });
const bty = board.createTopic({ text: 'Bounty: Pixelqueen', createdBy: 'chat', creatorUserId: viewer, creatorName: 'Viewer', kind: 'bounty', targetUserId: u3, headline: 'WANTED' });