openvibers/openvibe.live Commits
The Rundown's Today card: AI-written, take-linked, and on the page (#824)
Changes
@@@ -102,6 +102,7 @@ The streamer can delete their own items. Each delete asks first, in the page (a- `/rundown`, the front page:- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;++ - **Today's rundown:** one AI-written card from today's takes: a headline, the day in a few sentences and up to three highlights, each linking to the take it came from (rewritten at most every 30 minutes while new takes arrive);- **Top take:** the best take of the last day (else the week), its quote set large;- **Stream by stream:** each stream the desk wrote about, newest first, with its episode title and logline (or, live, a Live now tag) and its 3 best takes on a timeline: the moment's timestamp (▶ to the VOD), the topic, the headline, the quote (else what they talked about), the desk's quip and reactions. "Read the episode" opens the rest. 8 streams a page (`/api/rundown/streams?before=`);- **Trending topics** this week (most streamers, then most takes);@@@ -123,10 +124,23 @@ The streamer can delete their own items. Each delete asks first, in the page (a`GET /api/rundown/day/:date` (also `today`) returns an AI Today's UTC day summary and a paginated, chronologicaltimeline. Query `offset` (default `0`) and `limit` (default `50`, maximum `100`) to page the timeline. The responseuses the same public audience rules as the Rundown pages; signed-in readers may also see their own non-public items.--The summary is owner-switch controlled by the owner-only site setting `ai_rundown_day_summary_enabled` (default off), is cached per UTC day, refreshes--at most hourly while that day is live and remains fixed after it ends. Days with fewer than three visible takes return--`summary.status: "too_few_takes"`. Each generated sentence carries `take_ids`; sentences without valid take citations--or which fail the existing explicit-content check are omitted; each sentence also includes `channel_ids` for channel links.++++`today` is the day's card (server/rundown/today.js): a `headline`, a `summary` of two to four sentences and up to++three `highlights`, each `{ take_id, line, stream_id, username, topic, url }` where `url` is the take's anchor on its++episode page (`/rundown/e/<streamId>#rd-take-<takeId>`). The card is written by OpenVibe.AI's `live.rundown.today`++workflow from up to 30 of the day's takes (sampled across the day when there are more), and is stored per UTC day.++The job checks every 5 minutes, writes the first card once the day has a visible take, and after that rewrites++it at most every 30 minutes and only when a take newer than the stored card has arrived; it starts nothing under++`LIVE_DRILL`. The stored words go through the same public-text filter as the rest of the Rundown and a card that++fails the explicit-content check is never stored or served. `null` means no card yet (too few takes, AI off, or the++job has not run). The page's server render shows the same card (kit markup, `server/rundown/today.js:ssrHtml`).++++The older `summary` (sentences with citations) is owner-switch controlled by the owner-only site setting++`ai_rundown_day_summary_enabled` (default off), is cached per UTC day, refreshes at most hourly while that day is++live and remains fixed after it ends. Days with fewer than three visible takes return `summary.status:++"too_few_takes"`. Each generated sentence carries `take_ids`; sentences without valid take citations or which fail++the existing explicit-content check are omitted; each sentence also includes `channel_ids` for channel links. When++`today` is ready the page draws that card in place of the sentences; the sentences remain as the fallback.Example response:@@@ -307,7 +307,7 @@"regenerateDashStreamKey"]},-- "rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},++ "rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css", "/shared/kit.css"], "js": ["/js/rundown.js"]},"recap": {"css": ["/css/recap.css",@@@ -933,7 +933,7 @@ async function _rdDayFetch() {items.push(...((more.timeline && more.timeline.items) || []));next = more.timeline && more.timeline.next_offset;}-- return { summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };++ return { today: first.today || null, summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };} catch { return null; } // the front page is whole without it}function _rdDayEl(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }@@@ -962,19 +962,44 @@ function _rdDayMoment(it) {}return li;}++// The AI card: what today was about in a few sentences, and up to three highlights, each a link to its take++// (GET /api/rundown/day/today's `today`; server/rundown/today.js writes it). DOM nodes only, like the rest.++function _rdTodayCard(today) {++ const card = _rdDayEl('div', 'rd-day-sum');++ card.append(_rdDayEl('p', 'rd-day-sum-k', "Today's rundown"));++ if (today.headline) card.append(_rdDayEl('p', 'rd-h3', today.headline));++ if (today.summary) card.append(_rdDayEl('p', 'rd-what', today.summary));++ const highlights = (today.highlights || []).filter((h) => h && h.line && h.url);++ if (highlights.length) {++ const list = _rdDayEl('ul', 'rd-today-highlights');++ highlights.forEach((h) => {++ const li = _rdDayEl('li');++ const link = _rdDayLink(h.url, '', h.line, false); // a deep link: the browser lands on the take's anchor++ li.append(link);++ const who = [h.username, h.topic].filter(Boolean).join(' \u00b7 ');++ if (who) li.append(_rdDayEl('span', 'rd-meta', ` \u2014 ${who}`));++ list.append(li);++ });++ card.append(list);++ }++ card.append(_rdDayEl('p', 'rd-day-sum-by', 'Written by AI from today\u2019s takes. Each highlight links to the take it came from.'));++ return card;++}function _rdDayDraw(day) {const host = document.getElementById('rd-day-host');if (!host || !day) return;++ const today = day.today && day.today.status === 'ready' && day.today.headline && day.today.summary ? day.today : null;const sum = day.summary;const sentences = sum && sum.status === 'ready' ? (sum.sentences || []).filter((x) => x && x.text) : [];-- if (!day.items.length && !sentences.length) return;++ if (!day.items.length && !sentences.length && !today) return;const sec = _rdDayEl('section', 'rd-day');sec.setAttribute('aria-labelledby', 'rd-day-h');const h = _rdDayEl('h2', 'rd-h2', 'Today, as it happened ');h.id = 'rd-day-h';if (day.items.length) h.append(_rdDayEl('small', '', `${day.total} moment${day.total === 1 ? '' : 's'} so far`));sec.append(h);-- if (sentences.length) {++ if (today) sec.append(_rdTodayCard(today));++ else if (sentences.length) {const card = _rdDayEl('div', 'rd-day-sum');card.append(_rdDayEl('p', 'rd-day-sum-k', 'The day in short'));sentences.forEach((x) => card.append(_rdDayEl('p', '', x.text)));@@@ -3788,7 +3788,7 @@ module.exports = {setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,-- cleanupMalformedAiText, publicMemoryText: _publicMemoryText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,++ cleanupMalformedAiText, publicMemoryText: _publicMemoryText, plainProse: _plainProse, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,upsertStreamerOverview, getStreamerOverview, shapeStoredStreamerOverviewShorts, getAllStreamerOverviews, getStreamersNeedingOverview,@@@ -1598,6 +1598,8 @@ async function start() {try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }++ // The Rundown's Today card: one AI-written card a day, rewritten at most every 30 minutes while new takes arrive.++ try { require('./rundown/today').start(); } catch (e) { console.warn('[Rundown] Today card job not started:', e.message); }try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }// An agent stream whose publish ticket expired with no renewal ends; expired tickets and sharing deliveries// are pruned on the same minute (server/agent-streams/sweep.js).@@@ -71,6 +71,8 @@ router.get('/day/:date', optionalAuth, async (req, res) => {try {const viewer = await viewerOf(req);const payload = await day.getDay(String(req.params.date || ''), { viewer, offset: req.query.offset, limit: req.query.limit });++ // The AI Today card: the page's top card, written by the job (server/rundown/today.js) from today's takes.++ try { payload.today = await require('./today').get(payload.date); } catch { payload.today = null; }res.set('Cache-Control', viewer.userId ? 'private, no-store' : 'public, max-age=60, stale-while-revalidate=300');res.json(payload);} catch (err) { fail(res, err, 'Failed to load the day'); }nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++/**++ * The Rundown's Today card (docs/rundown.md): what today was about, written by the desk from today's takes.++ *++ * The card is written by OpenVibe.AI (`live.rundown.today`): a headline, two to four sentences and up to three++ * highlights, each pointing at the take it came from. Its take ids are checked against today's public takes here,++ * so a highlight can never link to something that is not on the page, and the public-text rules apply: the stored++ * words go through the same text filter as the rest of the Rundown (`db.publicMemoryText`), and a card that says++ * anything sexual (server/ai/explicit.js) is never stored or served.++ *++ * refresh(date) ask the AI and store the card, when it is due++ * get(date) the stored card for that UTC day, or null++ * tick() the job: today's card, at most once every 30 minutes, only while there are new takes++ * ssrHtml(card) the server-rendered card (kit markup); the page's script draws the same card in the browser++ *++ * The job is due-based, not clock-based: the first three takes of the day make a card, then it is rewritten only++ * when at least REFRESH_MS has passed since the last one AND a take newer than the card's latest has arrived.++ * It starts nothing under LIVE_DRILL (a restore drill answers reads only).++ */++const db = require('../db/database');++const day = require('./day');++const drill = require('../drill');++const explicit = require('../ai/explicit');++const kit = require('openvibe-shared/kit');++++const REFRESH_MS = 30 * 60 * 1000; // at most one AI write per half hour++const CHECK_MS = 5 * 60 * 1000; // how often the job looks for a due day++const MIN_TAKES = 1; // one take is a day: the card says what was said, and grows with the day++const MAX_TAKES = 30; // the workflow takes at most 30 takes++const MAX_FETCH = 400; // a very busy day is sampled, not read whole++const KEY = (date) => `rundown_today:${date}`;++++const utcDay = (now = Date.now()) => new Date(now).toISOString().slice(0, 10);++const clamp = (t, n) => {++ const s = db.plainProse(db.publicMemoryText(String(t == null ? '' : t))).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();++ return s.slice(0, n) || null;++};++const takeUrl = (streamId, takeId) => `/rundown/e/${Number(streamId)}#rd-take-${Number(takeId)}`;++++/** Today's public takes, oldest first, up to MAX_FETCH (the day endpoint's own audience rule). */++async function takesFor(date) {++ const out = [];++ let offset = 0, total = 0, got = 0;++ do {++ const page = await day.dayData(date, { offset, limit: 100 });++ out.push(...page.all);++ total = page.total;++ got = page.all.length;++ offset += got;++ } while (got === 100 && out.length < Math.min(total, MAX_FETCH));++ return { takes: out, total };++}++++/** Up to max takes, across the whole day (first and last kept), in time order. */++function sampleTakes(items, max = MAX_TAKES) {++ const list = Array.isArray(items) ? items : [];++ if (list.length <= max) return list.slice();++ const out = [];++ for (let i = 0; i < max; i++) out.push(list[Math.round(i * (list.length - 1) / (max - 1))]);++ return out;++}++++/** The workflow input: only what the model may say, each take with the id its highlight must copy. */++function cardInput(date, items) {++ return {++ date,++ takes: items.map((t) => ({++ take_id: Number(t.id), streamer: t.channel && t.channel.username ? t.channel.username : 'someone',++ when: t.time || null, topic: t.topic && t.topic.title ? t.topic.title : null,++ what: t.what || null, take: t.take || null,++ })),++ };++}++++/**++ * Is a card due? Never before MIN_TAKES; the first card is due at once; after that it waits out REFRESH_MS and++ * needs a take newer than the one the stored card last saw.++ */++function isDue(stored, { now = Date.now(), total = 0, latest_take_id = null } = {}) {++ if (Number(total) < MIN_TAKES) return false;++ if (!stored || !Number(stored.generated_at)) return true;++ if (now - Number(stored.generated_at) < REFRESH_MS) return false;++ const seen = Number(stored.latest_take_id || 0);++ return latest_take_id != null && Number(latest_take_id) > seen;++}++++/** The card's words are public: the text filter and the explicit guard, as every other public AI text. */++function cardText(out) {++ if (!out || typeof out !== 'object') return null;++ const headline = clamp(out.headline, 100);++ const summary = clamp(out.summary, 700);++ if (!headline || !summary) return null;++ const highlights = (Array.isArray(out.highlights) ? out.highlights : [])++ .map((h) => ({ take_id: Number(h && h.take_id), line: clamp(h && h.line, 240) }))++ .filter((h) => Number.isInteger(h.take_id) && h.take_id > 0 && h.line)++ .filter((h, i, all) => all.findIndex((x) => x.take_id === h.take_id) === i)++ .slice(0, 3);++ if (explicit.isExplicit(headline, summary, ...highlights.map((h) => h.line))) return null;++ return { headline, summary, highlights };++}++++function enrich(out, items, total) {++ const byId = new Map(items.map((t) => [Number(t.id), t]));++ const seen = new Set();++ const highlights = [];++ for (const h of out.highlights) {++ const t = byId.get(h.take_id);++ if (!t || seen.has(h.take_id)) continue;++ seen.add(h.take_id);++ highlights.push({++ take_id: h.take_id,++ line: h.line,++ stream_id: Number(t.stream_id),++ username: t.channel && t.channel.username ? t.channel.username : null,++ topic: t.topic && t.topic.title ? t.topic.title : null,++ url: takeUrl(t.stream_id, h.take_id),++ });++ if (highlights.length >= 3) break;++ }++ return { ...out, highlights };++}++++/** The stored card for that UTC day, or null. */++async function get(date) {++ if (!date) return null;++ try {++ const raw = await db.getSetting(KEY(date));++ const card = raw ? JSON.parse(raw) : null;++ return card && card.status === 'ready' && card.date === date ? card : null;++ } catch { return null; }++}++++async function siteOn() {++ try { const v = await db.getSetting('rundown_enabled'); return v === null || v === undefined || v === true || v === 'true' || v === 1 || v === '1'; } catch { return true; }++}++async function aiOn() {++ try { const llm = require('../ai/llm'); return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; }++}++++/**++ * Write today's card if it is due. ai is injectable for tests (a function like ai-service.structured).++ * Returns the stored card, the existing card when not due, or null when there was nothing to write.++ */++async function refresh(date, { now = Date.now(), force = false, ai = null } = {}) {++ if (drill.enabled) return null;++ if (date === 'today' || !date) date = utcDay(now);++ if (!await siteOn()) return null;++ if (!ai && !await aiOn()) return null;++ const { takes, total } = await takesFor(date);++ const latest = takes.reduce((n, t) => Math.max(n, Number(t.id) || 0), 0);++ const stored = await get(date);++ if (!force && !isDue(stored, { now, total, latest_take_id: latest })) return stored;++ const sample = sampleTakes(takes);++ const input = cardInput(date, sample);++ let out = null;++ try { out = await (ai || require('../ai/ai-service').structured)('live.rundown.today', input, { meter: { kind: 'rundown_today', role: 'chat', source: 'rundown' } }); }++ catch { out = null; }++ const text = cardText(out);++ if (!text) return null;++ const card = { status: 'ready', date, ...enrich(text, sample, total), generated_at: now, latest_take_id: latest, take_count: total, take_sample: sample.length };++ try { await db.setSetting(KEY(date), JSON.stringify(card)); } catch { return null; }++ return card;++}++++async function tick(now = Date.now()) {++ const date = utcDay(now);++ const card = await refresh(date, { now });++ // refresh() also returns the stored card when the day is not due; only a card written now is news.++ if (card && Number(card.generated_at) === Number(now)) console.log(`[Rundown] Today card for ${date}: ${card.take_count} take(s), ${card.highlights.length} highlight(s)`);++ return card;++}++++let _job = null;++function start() {++ if (_job || drill.enabled) return;++ _job = require('../utils/jobs').every('rundown-today', CHECK_MS, () => tick().catch((e) => console.warn('[Rundown] today card:', e.message)), { initialDelayMs: 2 * 60 * 1000, jitterMs: 30 * 1000 });++ console.log(`[Rundown] Today card job started (checks every ${CHECK_MS / 60000} min, writes at most every ${REFRESH_MS / 60000} min while there are new takes)`);++}++function stop() { if (typeof _job === 'function') _job(); _job = null; }++++/** The server-rendered card, kit markup: headline, the day in a few sentences, each highlight a link to its take. */++function ssrHtml(card) {++ if (!card || card.status !== 'ready' || !card.headline || !card.summary) return '';++ const rows = (card.highlights || []).map((h) => kit.row({++ href: h.url || takeUrl(h.stream_id, h.take_id),++ title: h.line,++ sub: [h.username, h.topic].filter(Boolean).join(' · '),++ }));++ return kit.section({++ title: "Today's rundown", icon: 'spark', note: 'Written by AI from today’s takes.',++ body: `<p class="rd-today-headline"><strong>${kit.esc(card.headline)}</strong></p><p class="rd-today-sum">${kit.esc(card.summary)}</p>`++ + (rows.length ? kit.list(rows, { title: 'Highlights' }) : ''),++ });++}++++module.exports = {++ refresh, get, tick, start, stop, ssrHtml, isDue, sampleTakes, cardInput, cardText, takesFor, utcDay,++ REFRESH_MS, CHECK_MS, MIN_TAKES, MAX_TAKES, KEY,++};@@@ -524,7 +524,12 @@ async function _rundownMeta(p, query, page = 1) {const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);const takes = feed && feed.takes || [];const episodes = feed && feed.episodes || [];-- const body = feed ? `<header class="rd-mast"><h1 class="rd-title">The Rundown</h1><p class="rd-tagline">${esc(desc)}</p></header><main class="rd-ssr-feed"><h2 class="rd-h2">Latest takes</h2>${takes.map((s) => `<article class="rd-card rd-take"><p class="rd-meta">${esc(s.user && (s.user.display_name || s.user.username) || '')} · ${esc(s.topic && s.topic.title || '')}</p><h3 class="rd-h3">${esc(s.title)}</h3><p class="rd-what">${esc((s.quotes && s.quotes[0] && s.quotes[0].text) || s.what || '')}</p></article>`).join('')}<h2 class="rd-h2">Episodes</h2>${episodes.map((e) => `<article class="rd-card rd-episode"><h3 class="rd-h3"><a href="/rundown/e/${Number(e.stream_id)}">${esc(e.title)}</a></h3><p class="rd-logline">${esc(e.logline || '')}</p></article>`).join('')}${feed.next_page ? `<a class="rd-next" rel="next" href="/rundown?page=${feed.next_page}">More takes and episodes</a>` : ''}</main>` : null;++ // The AI Today card (server/rundown/today.js): the day in a few sentences, each highlight a link to its take.++ let todayHtml = '';++ if (page === 1) {++ try { todayHtml = require('../rundown/today').ssrHtml(await require('../rundown/today').get(new Date().toISOString().slice(0, 10))); } catch { todayHtml = ''; }++ }++ const body = feed ? `<header class="rd-mast"><h1 class="rd-title">The Rundown</h1><p class="rd-tagline">${esc(desc)}</p></header>${todayHtml}<main class="rd-ssr-feed"><h2 class="rd-h2">Latest takes</h2>${takes.map((s) => `<article class="rd-card rd-take"><p class="rd-meta">${esc(s.user && (s.user.display_name || s.user.username) || '')} · ${esc(s.topic && s.topic.title || '')}</p><h3 class="rd-h3">${esc(s.title)}</h3><p class="rd-what">${esc((s.quotes && s.quotes[0] && s.quotes[0].text) || s.what || '')}</p></article>`).join('')}<h2 class="rd-h2">Episodes</h2>${episodes.map((e) => `<article class="rd-card rd-episode"><h3 class="rd-h3"><a href="/rundown/e/${Number(e.stream_id)}">${esc(e.title)}</a></h3><p class="rd-logline">${esc(e.logline || '')}</p></article>`).join('')}${feed.next_page ? `<a class="rd-next" rel="next" href="/rundown?page=${feed.next_page}">More takes and episodes</a>` : ''}</main>` : null;return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: page > 1 ? `/rundown?page=${page}` : '/rundown', robots: 'index,follow', rundownBody: body,jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/rundown'), description: desc }),...(takes.length ? [seo.jsonLd.itemList('Latest takes', takes.map((s) => ({ name: clean(s.title, 110), url: abs(`/rundown/e/${s.stream_id}#rd-take-${s.id}`) })))] : []),@@@ -961,7 +966,11 @@ function render(meta, urlPath) {let html = _base();if (!html) return null;html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');-- if (meta.rundownBody) html = html.replace('<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>', meta.rundownBody);++ if (meta.rundownBody) {++ // The fragment's loading state is the shared spinner (an ov-mark since the 2026-11 loading look); replace the++ // one inside #page-rundown only, since the shell holds the same spinner for other pages.++ html = html.replace(/(<section id="page-rundown"[^>]*>[\s\S]*?)<div class="loading-spinner"><span class="ov-mark"[^>]*><\/span><\/div>/, (m, pre) => pre + meta.rundownBody);++ }// The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,// filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.@@@ -18,7 +18,10 @@ const day = js.slice(from, to);assert.ok(day.includes("api('/rundown/day/today?limit=100')"), 'it reads the day endpoint');assert.ok(!/innerHTML|insertAdjacentHTML|outerHTML/.test(day), 'the day section is built with DOM nodes, never markup strings');assert.ok(day.includes("sum.status === 'ready'"), 'the day in short shows only when the summary is ready (off, too few takes: no card)');--assert.ok(day.includes('if (!day.items.length && !sentences.length) return;'), 'nothing to show: nothing is drawn');++assert.ok(day.includes('today: first.today || null') && day.includes("day.today && day.today.status === 'ready'"), 'the AI Today card comes from the day endpoint and shows only when it is ready');++assert.ok(day.includes('function _rdTodayCard(today)') && day.includes('_rdTodayCard(today)'), 'the Today card is drawn above the moments');++assert.ok(day.includes('_rdDayLink(h.url, \'\', h.line, false)') && day.includes('h.url'), 'each highlight is a link to its take');++assert.ok(day.includes('if (!day.items.length && !sentences.length && !today) return;'), 'nothing to show: nothing is drawn');assert.ok(day.includes("'Written by AI from today\\u2019s takes."), 'the summary says who wrote it');assert.ok(day.includes('rail.scrollLeft = rail.scrollWidth;'), 'the row opens at the newest moment');assert.ok(/for \(let i = 0; next != null && i < 3; i\+\+\)/.test(day), 'a busy day is read in a bounded number of pages');nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// The Rundown's Today card (server/rundown/today.js): the job is due-based — the first three takes make a card,++// then at most one write per 30 minutes and only when a take newer than the stored card exists; today's takes are++// sampled across the day; the stored words pass the public-text filter and the explicit guard; a highlight can only++// point at a take it was given; the server-rendered card links every highlight to its take's anchor.++const assert = require('assert');++const db = require('../server/db/database');++const today = require('../server/rundown/today');++++(async () => {++ const now = Date.parse('2026-11-11T12:00:00Z');++ assert.strictEqual(today.utcDay(now), '2026-11-11');++ assert.strictEqual(today.utcDay(Date.parse('2026-11-11T23:59:59Z')), '2026-11-11');++++ // ── Cadence: never before three takes, then 30 minutes apart with a new take ──++ assert.strictEqual(today.isDue(null, { now, total: 0, latest_take_id: null }), false, 'a day with no takes has no card');++ assert.strictEqual(today.isDue(null, { now, total: 1, latest_take_id: 9 }), true, 'the first take makes a card');++ assert.strictEqual(today.isDue({ generated_at: now - 29 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 10 }), false, 'not before 30 minutes');++ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 9 }), false, 'no new takes, no write');++ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 10 }), true, 'a new take after 30 minutes');++ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 8 }), false, 'an older cursor never rewrites');++++ // ── Sampling: first and last of the day, no duplicates, up to 30 ──++ const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, title: `take ${i + 1}` }));++ const sample = today.sampleTakes(items, 30);++ assert.strictEqual(sample.length, 30);++ assert.strictEqual(sample[0].id, 1);++ assert.strictEqual(sample[29].id, 100);++ assert.strictEqual(new Set(sample.map((x) => x.id)).size, 30);++ assert.deepStrictEqual(today.sampleTakes(items.slice(0, 4), 30).map((x) => x.id), [1, 2, 3, 4], 'a small day is kept whole');++++ // ── The words are public text: clean, and a card that says something sexual is never stored ──++ const out = today.cardText({++ headline: '<b>Fridges and builds</b>',++ summary: '## The day\n- goosely read about fridges.\nann fixed a build.',++ highlights: [{ take_id: 1, line: 'The fridge line' }, { take_id: 1, line: 'The same take again' }, { take_id: 2, line: ' **It compiles!** ' }],++ });++ assert.deepStrictEqual(out, { headline: 'Fridges and builds', summary: 'goosely read about fridges. ann fixed a build.', highlights: [{ take_id: 1, line: 'The fridge line' }, { take_id: 2, line: 'It compiles!' }] });++ assert.strictEqual(today.cardText({ headline: 'A night', summary: 'They watched porn.', highlights: [] }), null, 'the explicit guard');++ assert.strictEqual(today.cardText({ headline: 'No summary', highlights: [] }), null);++++ // ── The server-rendered card: kit markup, every highlight a link to its take ──++ const html = today.ssrHtml({ status: 'ready', headline: 'Fridges & builds', summary: 'A day about fridges and one stubborn compiler.', highlights: [++ { take_id: 41, line: 'The fridge line', url: '/rundown/e/12#rd-take-41', username: 'goosely', topic: 'Fridges' },++ { take_id: 42, line: 'It compiles at last', stream_id: 13, username: 'ann', topic: 'Rust builds' },++ ] });++ assert.match(html, /class="ovk-section"/);++ assert.match(html, /Fridges & builds/);++ assert.match(html, /A day about fridges and one stubborn compiler\./);++ assert.match(html, /href="\/rundown\/e\/12#rd-take-41"/);++ assert.match(html, /href="\/rundown\/e\/13#rd-take-42"/);++ assert.match(html, /The fridge line/);++ assert.match(html, /goosely/);++ assert.strictEqual(today.ssrHtml(null), '', 'no card, no markup');++++ // ── Stored per day, and read back through the day's API shape ──++ await db.initDb();++ const mk = async (username, display) => {++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: display || username, stream_key: username.padEnd(32, '0') });++ const u = await db.get('SELECT id FROM users WHERE username = ?', [username]);++ await db.ensureChannel(u.id);++ return u.id;++ };++ const goose = await mk('goosely', 'goosely');++ const streamId = Number((await db.createStream({ user_id: goose, title: 'Tech news', category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);++ const date = '2026-11-11';++ for (let i = 0; i < 3; i++) {++ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, score, quotes_json, vod_id, said_at, status)++ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, 4, '[]', NULL, ?, 'live')`, [streamId, goose, 60 * i, 60 * i + 40, `Take ${i}`, `goosely talked about fridges (${i}).`, i === 1 ? 'Internet fridges are a bad idea.' : null, `${date} 1${i}:00:00`]);++ }++ const ids = (await db.all('SELECT id FROM rundown_segments ORDER BY id')).map((r) => Number(r.id));++ const card = await today.refresh(date, { now, ai: async () => ({++ headline: 'Fridges and the build',++ summary: 'goosely spent the morning on hacked fridges. The take was that the internet does not belong in a fridge.',++ highlights: [{ take_id: ids[1], line: 'The fridge take that started it all.' }, { take_id: 999999, line: 'Not a take of today.' }],++ }) });++ assert.ok(card, 'the first card is written');++ assert.strictEqual(card.status, 'ready');++ assert.strictEqual(card.take_count, 3);++ assert.strictEqual(card.highlights.length, 1, 'only a real take becomes a highlight');++ assert.deepStrictEqual(card.highlights[0], {++ take_id: ids[1], line: 'The fridge take that started it all.', stream_id: streamId, username: 'goosely', topic: null, url: `/rundown/e/${streamId}#rd-take-${ids[1]}`,++ });++ assert.deepStrictEqual(await today.get(date), card, 'the card is stored and read back');++ assert.strictEqual(await today.get('2026-11-10'), null, 'another day has no card');++++ // Not due: the next looks return the same card and never call AI.++ let calls = 0;++ const noAi = async () => { calls++; return { headline: 'x', summary: 'y', highlights: [] }; };++ assert.deepStrictEqual(await today.refresh(date, { now: now + 5 * 60000, ai: noAi }), card);++ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, score, quotes_json, vod_id, said_at, status)++ VALUES (?, ?, NULL, 200, 240, 'Take 3', 'goosely kept talking about fridges.', NULL, 4, '[]', NULL, ?, 'live')`, [streamId, goose, `${date} 16:00:00`]);++ assert.deepStrictEqual(await today.refresh(date, { now: now + 10 * 60000, ai: noAi }), card);++ assert.strictEqual(calls, 0, 'a new take inside the half hour waits');++ const rewritten = await today.refresh(date, { now: now + 31 * 60000, ai: async () => ({ headline: 'Fridges, again', summary: 'goosely kept going on the fridges.', highlights: [] }) });++ assert.strictEqual(calls, 0);++ assert.strictEqual(rewritten.headline, 'Fridges, again');++ assert.strictEqual(rewritten.take_count, 4);++ const again = await today.refresh(date, { now: now + 40 * 60000, ai: noAi });++ assert.strictEqual(again.headline, 'Fridges, again');++ assert.strictEqual(calls, 0, 'no new take after the rewrite: no call');++++ console.log('rundown today card: cadence, storage, filter and render OK');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -400,6 +400,25 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));});++ await check('/rundown: the server render carries the AI Today card, each highlight linked to its take', async () => {++ const today = require('../server/rundown/today');++ const orig = today.get;++ today.get = async () => ({ status: 'ready', date: new Date().toISOString().slice(0, 10), headline: 'Fridges and builds', summary: 'A day about fridges and one stubborn compiler.',++ highlights: [++ { take_id: 41, line: 'The fridge line', stream_id: 2981, username: 'goosely', topic: 'Fridges', url: '/rundown/e/2981#rd-take-41' },++ { take_id: 42, line: 'It compiles at last', stream_id: 2982, username: 'ann', topic: 'Rust builds', url: '/rundown/e/2982#rd-take-42' },++ ] });++ try {++ const r = await get('/rundown');++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.includes('Today's rundown'), 'the card title');++ assert.ok(r.body.includes('Fridges and builds'));++ assert.ok(r.body.includes('A day about fridges and one stubborn compiler.'));++ assert.ok(r.body.includes('href="/rundown/e/2981#rd-take-41"'), 'the first highlight links to its take');++ assert.ok(r.body.includes('href="/rundown/e/2982#rd-take-42"'), 'the second highlight links to its take');++ } finally { today.get = orig; }++ });++server.close();quiet(failures ? `\n${failures} check(s) failed` : '\nseo ssr: all checks passed');process.exit(failures ? 1 : 0);
@@ -102,6 +102,7 @@ The streamer can delete their own items. Each delete asks first, in the page (a
@@ -102,6 +102,7 @@ The streamer can delete their own items. Each delete asks first, in the page (a
- `/rundown`, the front page:
- `/rundown`, the front page:
- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;
- the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;
+ - **Today's rundown:** one AI-written card from today's takes: a headline, the day in a few sentences and up to three highlights, each linking to the take it came from (rewritten at most every 30 minutes while new takes arrive);
- **Top take:** the best take of the last day (else the week), its quote set large;
- **Top take:** the best take of the last day (else the week), its quote set large;
- **Stream by stream:** each stream the desk wrote about, newest first, with its episode title and logline (or, live, a Live now tag) and its 3 best takes on a timeline: the moment's timestamp (▶ to the VOD), the topic, the headline, the quote (else what they talked about), the desk's quip and reactions. "Read the episode" opens the rest. 8 streams a page (`/api/rundown/streams?before=`);
- **Stream by stream:** each stream the desk wrote about, newest first, with its episode title and logline (or, live, a Live now tag) and its 3 best takes on a timeline: the moment's timestamp (▶ to the VOD), the topic, the headline, the quote (else what they talked about), the desk's quip and reactions. "Read the episode" opens the rest. 8 streams a page (`/api/rundown/streams?before=`);
- **Trending topics** this week (most streamers, then most takes);
- **Trending topics** this week (most streamers, then most takes);
@@ -123,10 +124,23 @@ The streamer can delete their own items. Each delete asks first, in the page (a
@@ -123,10 +124,23 @@ The streamer can delete their own items. Each delete asks first, in the page (a
`GET /api/rundown/day/:date` (also `today`) returns an AI Today's UTC day summary and a paginated, chronological
`GET /api/rundown/day/:date` (also `today`) returns an AI Today's UTC day summary and a paginated, chronological
timeline. Query `offset` (default `0`) and `limit` (default `50`, maximum `100`) to page the timeline. The response
timeline. Query `offset` (default `0`) and `limit` (default `50`, maximum `100`) to page the timeline. The response
uses the same public audience rules as the Rundown pages; signed-in readers may also see their own non-public items.
uses the same public audience rules as the Rundown pages; signed-in readers may also see their own non-public items.
-The summary is owner-switch controlled by the owner-only site setting `ai_rundown_day_summary_enabled` (default off), is cached per UTC day, refreshes
-at most hourly while that day is live and remains fixed after it ends. Days with fewer than three visible takes return
-`summary.status: "too_few_takes"`. Each generated sentence carries `take_ids`; sentences without valid take citations
-or which fail the existing explicit-content check are omitted; each sentence also includes `channel_ids` for channel links.
+
+`today` is the day's card (server/rundown/today.js): a `headline`, a `summary` of two to four sentences and up to
+three `highlights`, each `{ take_id, line, stream_id, username, topic, url }` where `url` is the take's anchor on its
+episode page (`/rundown/e/<streamId>#rd-take-<takeId>`). The card is written by OpenVibe.AI's `live.rundown.today`
+workflow from up to 30 of the day's takes (sampled across the day when there are more), and is stored per UTC day.
+The job checks every 5 minutes, writes the first card once the day has a visible take, and after that rewrites
+it at most every 30 minutes and only when a take newer than the stored card has arrived; it starts nothing under
+`LIVE_DRILL`. The stored words go through the same public-text filter as the rest of the Rundown and a card that
+fails the explicit-content check is never stored or served. `null` means no card yet (too few takes, AI off, or the
+job has not run). The page's server render shows the same card (kit markup, `server/rundown/today.js:ssrHtml`).
+
+The older `summary` (sentences with citations) is owner-switch controlled by the owner-only site setting
+`ai_rundown_day_summary_enabled` (default off), is cached per UTC day, refreshes at most hourly while that day is
+live and remains fixed after it ends. Days with fewer than three visible takes return `summary.status:
+"too_few_takes"`. Each generated sentence carries `take_ids`; sentences without valid take citations or which fail
+the existing explicit-content check are omitted; each sentence also includes `channel_ids` for channel links. When
+`today` is ready the page draws that card in place of the sentences; the sentences remain as the fallback.
Example response:
Example response:
@@ -307,7 +307,7 @@
@@ -307,7 +307,7 @@
"regenerateDashStreamKey"
"regenerateDashStreamKey"
]
]
},
},
- "rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
+ "rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css", "/shared/kit.css"], "js": ["/js/rundown.js"]},
"recap": {
"recap": {
"css": [
"css": [
"/css/recap.css",
"/css/recap.css",
@@ -933,7 +933,7 @@ async function _rdDayFetch() {
@@ -933,7 +933,7 @@ async function _rdDayFetch() {
items.push(...((more.timeline && more.timeline.items) || []));
items.push(...((more.timeline && more.timeline.items) || []));
next = more.timeline && more.timeline.next_offset;
next = more.timeline && more.timeline.next_offset;
}
}
- return { summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };
+ return { today: first.today || null, summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };
} catch { return null; } // the front page is whole without it
} catch { return null; } // the front page is whole without it
}
}
function _rdDayEl(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
function _rdDayEl(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
@@ -962,19 +962,44 @@ function _rdDayMoment(it) {
@@ -962,19 +962,44 @@ function _rdDayMoment(it) {
}
}
return li;
return li;
}
}
+// The AI card: what today was about in a few sentences, and up to three highlights, each a link to its take
+// (GET /api/rundown/day/today's `today`; server/rundown/today.js writes it). DOM nodes only, like the rest.
+function _rdTodayCard(today) {
+ const card = _rdDayEl('div', 'rd-day-sum');
+ card.append(_rdDayEl('p', 'rd-day-sum-k', "Today's rundown"));
+ if (today.headline) card.append(_rdDayEl('p', 'rd-h3', today.headline));
+ if (today.summary) card.append(_rdDayEl('p', 'rd-what', today.summary));
+ const highlights = (today.highlights || []).filter((h) => h && h.line && h.url);
+ if (highlights.length) {
+ const list = _rdDayEl('ul', 'rd-today-highlights');
+ highlights.forEach((h) => {
+ const li = _rdDayEl('li');
+ const link = _rdDayLink(h.url, '', h.line, false); // a deep link: the browser lands on the take's anchor
+ li.append(link);
+ const who = [h.username, h.topic].filter(Boolean).join(' \u00b7 ');
+ if (who) li.append(_rdDayEl('span', 'rd-meta', ` \u2014 ${who}`));
+ list.append(li);
+ });
+ card.append(list);
+ }
+ card.append(_rdDayEl('p', 'rd-day-sum-by', 'Written by AI from today\u2019s takes. Each highlight links to the take it came from.'));
+ return card;
+}
function _rdDayDraw(day) {
function _rdDayDraw(day) {
const host = document.getElementById('rd-day-host');
const host = document.getElementById('rd-day-host');
if (!host || !day) return;
if (!host || !day) return;
+ const today = day.today && day.today.status === 'ready' && day.today.headline && day.today.summary ? day.today : null;
const sum = day.summary;
const sum = day.summary;
const sentences = sum && sum.status === 'ready' ? (sum.sentences || []).filter((x) => x && x.text) : [];
const sentences = sum && sum.status === 'ready' ? (sum.sentences || []).filter((x) => x && x.text) : [];
- if (!day.items.length && !sentences.length) return;
+ if (!day.items.length && !sentences.length && !today) return;
const sec = _rdDayEl('section', 'rd-day');
const sec = _rdDayEl('section', 'rd-day');
sec.setAttribute('aria-labelledby', 'rd-day-h');
sec.setAttribute('aria-labelledby', 'rd-day-h');
const h = _rdDayEl('h2', 'rd-h2', 'Today, as it happened ');
const h = _rdDayEl('h2', 'rd-h2', 'Today, as it happened ');
h.id = 'rd-day-h';
h.id = 'rd-day-h';
if (day.items.length) h.append(_rdDayEl('small', '', `${day.total} moment${day.total === 1 ? '' : 's'} so far`));
if (day.items.length) h.append(_rdDayEl('small', '', `${day.total} moment${day.total === 1 ? '' : 's'} so far`));
sec.append(h);
sec.append(h);
- if (sentences.length) {
+ if (today) sec.append(_rdTodayCard(today));
+ else if (sentences.length) {
const card = _rdDayEl('div', 'rd-day-sum');
const card = _rdDayEl('div', 'rd-day-sum');
card.append(_rdDayEl('p', 'rd-day-sum-k', 'The day in short'));
card.append(_rdDayEl('p', 'rd-day-sum-k', 'The day in short'));
sentences.forEach((x) => card.append(_rdDayEl('p', '', x.text)));
sentences.forEach((x) => card.append(_rdDayEl('p', '', x.text)));
@@ -3788,7 +3788,7 @@ module.exports = {
@@ -3788,7 +3788,7 @@ module.exports = {
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
getVodsNeedingOverview, getClipsNeedingOverview, getVodsNeedingTimeline, getVodsNeedingTranscript, getClipsNeedingTranscript, getMediaNeedingAiAnalysis,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
setVodTranscriptStatus, setClipTranscriptStatus, bumpVodTranscriptAttempt, bumpClipTranscriptAttempt,
- cleanupMalformedAiText, publicMemoryText: _publicMemoryText, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
+ cleanupMalformedAiText, publicMemoryText: _publicMemoryText, plainProse: _plainProse, recordAiUsage, getAiCostToday, getAiCostTodayForUser, getAiUsageSummary,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
getStreamMemoriesByUser, countStreamMemoriesByUser, getStreamTranscriptSegments,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
addTimelineEvents, getTimeline, getTimelineText, getTimelineCoverage, linkTimelineToVod, getTimelineByVod, getTimelineVodId,
upsertStreamerOverview, getStreamerOverview, shapeStoredStreamerOverviewShorts, getAllStreamerOverviews, getStreamersNeedingOverview,
upsertStreamerOverview, getStreamerOverview, shapeStoredStreamerOverviewShorts, getAllStreamerOverviews, getStreamersNeedingOverview,
@@ -1598,6 +1598,8 @@ async function start() {
@@ -1598,6 +1598,8 @@ async function start() {
try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }
try { require('./streaming/pip-history').start(); } catch (e) { console.warn('[PiP history] not started:', e.message); }
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
// The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
+ // The Rundown's Today card: one AI-written card a day, rewritten at most every 30 minutes while new takes arrive.
+ try { require('./rundown/today').start(); } catch (e) { console.warn('[Rundown] Today card job not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
// An agent stream whose publish ticket expired with no renewal ends; expired tickets and sharing deliveries
// An agent stream whose publish ticket expired with no renewal ends; expired tickets and sharing deliveries
// are pruned on the same minute (server/agent-streams/sweep.js).
// are pruned on the same minute (server/agent-streams/sweep.js).
@@ -71,6 +71,8 @@ router.get('/day/:date', optionalAuth, async (req, res) => {
@@ -71,6 +71,8 @@ router.get('/day/:date', optionalAuth, async (req, res) => {
try {
try {
const viewer = await viewerOf(req);
const viewer = await viewerOf(req);
const payload = await day.getDay(String(req.params.date || ''), { viewer, offset: req.query.offset, limit: req.query.limit });
const payload = await day.getDay(String(req.params.date || ''), { viewer, offset: req.query.offset, limit: req.query.limit });
+ // The AI Today card: the page's top card, written by the job (server/rundown/today.js) from today's takes.
+ try { payload.today = await require('./today').get(payload.date); } catch { payload.today = null; }
res.set('Cache-Control', viewer.userId ? 'private, no-store' : 'public, max-age=60, stale-while-revalidate=300');
res.set('Cache-Control', viewer.userId ? 'private, no-store' : 'public, max-age=60, stale-while-revalidate=300');
res.json(payload);
res.json(payload);
} catch (err) { fail(res, err, 'Failed to load the day'); }
} catch (err) { fail(res, err, 'Failed to load the day'); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+/**
+ * The Rundown's Today card (docs/rundown.md): what today was about, written by the desk from today's takes.
+ *
+ * The card is written by OpenVibe.AI (`live.rundown.today`): a headline, two to four sentences and up to three
+ * highlights, each pointing at the take it came from. Its take ids are checked against today's public takes here,
+ * so a highlight can never link to something that is not on the page, and the public-text rules apply: the stored
+ * words go through the same text filter as the rest of the Rundown (`db.publicMemoryText`), and a card that says
+ * anything sexual (server/ai/explicit.js) is never stored or served.
+ *
+ * refresh(date) ask the AI and store the card, when it is due
+ * get(date) the stored card for that UTC day, or null
+ * tick() the job: today's card, at most once every 30 minutes, only while there are new takes
+ * ssrHtml(card) the server-rendered card (kit markup); the page's script draws the same card in the browser
+ *
+ * The job is due-based, not clock-based: the first three takes of the day make a card, then it is rewritten only
+ * when at least REFRESH_MS has passed since the last one AND a take newer than the card's latest has arrived.
+ * It starts nothing under LIVE_DRILL (a restore drill answers reads only).
+ */
+const db = require('../db/database');
+const day = require('./day');
+const drill = require('../drill');
+const explicit = require('../ai/explicit');
+const kit = require('openvibe-shared/kit');
+
+const REFRESH_MS = 30 * 60 * 1000; // at most one AI write per half hour
+const CHECK_MS = 5 * 60 * 1000; // how often the job looks for a due day
+const MIN_TAKES = 1; // one take is a day: the card says what was said, and grows with the day
+const MAX_TAKES = 30; // the workflow takes at most 30 takes
+const MAX_FETCH = 400; // a very busy day is sampled, not read whole
+const KEY = (date) => `rundown_today:${date}`;
+
+const utcDay = (now = Date.now()) => new Date(now).toISOString().slice(0, 10);
+const clamp = (t, n) => {
+ const s = db.plainProse(db.publicMemoryText(String(t == null ? '' : t))).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ return s.slice(0, n) || null;
+};
+const takeUrl = (streamId, takeId) => `/rundown/e/${Number(streamId)}#rd-take-${Number(takeId)}`;
+
+/** Today's public takes, oldest first, up to MAX_FETCH (the day endpoint's own audience rule). */
+async function takesFor(date) {
+ const out = [];
+ let offset = 0, total = 0, got = 0;
+ do {
+ const page = await day.dayData(date, { offset, limit: 100 });
+ out.push(...page.all);
+ total = page.total;
+ got = page.all.length;
+ offset += got;
+ } while (got === 100 && out.length < Math.min(total, MAX_FETCH));
+ return { takes: out, total };
+}
+
+/** Up to max takes, across the whole day (first and last kept), in time order. */
+function sampleTakes(items, max = MAX_TAKES) {
+ const list = Array.isArray(items) ? items : [];
+ if (list.length <= max) return list.slice();
+ const out = [];
+ for (let i = 0; i < max; i++) out.push(list[Math.round(i * (list.length - 1) / (max - 1))]);
+ return out;
+}
+
+/** The workflow input: only what the model may say, each take with the id its highlight must copy. */
+function cardInput(date, items) {
+ return {
+ date,
+ takes: items.map((t) => ({
+ take_id: Number(t.id), streamer: t.channel && t.channel.username ? t.channel.username : 'someone',
+ when: t.time || null, topic: t.topic && t.topic.title ? t.topic.title : null,
+ what: t.what || null, take: t.take || null,
+ })),
+ };
+}
+
+/**
+ * Is a card due? Never before MIN_TAKES; the first card is due at once; after that it waits out REFRESH_MS and
+ * needs a take newer than the one the stored card last saw.
+ */
+function isDue(stored, { now = Date.now(), total = 0, latest_take_id = null } = {}) {
+ if (Number(total) < MIN_TAKES) return false;
+ if (!stored || !Number(stored.generated_at)) return true;
+ if (now - Number(stored.generated_at) < REFRESH_MS) return false;
+ const seen = Number(stored.latest_take_id || 0);
+ return latest_take_id != null && Number(latest_take_id) > seen;
+}
+
+/** The card's words are public: the text filter and the explicit guard, as every other public AI text. */
+function cardText(out) {
+ if (!out || typeof out !== 'object') return null;
+ const headline = clamp(out.headline, 100);
+ const summary = clamp(out.summary, 700);
+ if (!headline || !summary) return null;
+ const highlights = (Array.isArray(out.highlights) ? out.highlights : [])
+ .map((h) => ({ take_id: Number(h && h.take_id), line: clamp(h && h.line, 240) }))
+ .filter((h) => Number.isInteger(h.take_id) && h.take_id > 0 && h.line)
+ .filter((h, i, all) => all.findIndex((x) => x.take_id === h.take_id) === i)
+ .slice(0, 3);
+ if (explicit.isExplicit(headline, summary, ...highlights.map((h) => h.line))) return null;
+ return { headline, summary, highlights };
+}
+
+function enrich(out, items, total) {
+ const byId = new Map(items.map((t) => [Number(t.id), t]));
+ const seen = new Set();
+ const highlights = [];
+ for (const h of out.highlights) {
+ const t = byId.get(h.take_id);
+ if (!t || seen.has(h.take_id)) continue;
+ seen.add(h.take_id);
+ highlights.push({
+ take_id: h.take_id,
+ line: h.line,
+ stream_id: Number(t.stream_id),
+ username: t.channel && t.channel.username ? t.channel.username : null,
+ topic: t.topic && t.topic.title ? t.topic.title : null,
+ url: takeUrl(t.stream_id, h.take_id),
+ });
+ if (highlights.length >= 3) break;
+ }
+ return { ...out, highlights };
+}
+
+/** The stored card for that UTC day, or null. */
+async function get(date) {
+ if (!date) return null;
+ try {
+ const raw = await db.getSetting(KEY(date));
+ const card = raw ? JSON.parse(raw) : null;
+ return card && card.status === 'ready' && card.date === date ? card : null;
+ } catch { return null; }
+}
+
+async function siteOn() {
+ try { const v = await db.getSetting('rundown_enabled'); return v === null || v === undefined || v === true || v === 'true' || v === 1 || v === '1'; } catch { return true; }
+}
+async function aiOn() {
+ try { const llm = require('../ai/llm'); return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; }
+}
+
+/**
+ * Write today's card if it is due. ai is injectable for tests (a function like ai-service.structured).
+ * Returns the stored card, the existing card when not due, or null when there was nothing to write.
+ */
+async function refresh(date, { now = Date.now(), force = false, ai = null } = {}) {
+ if (drill.enabled) return null;
+ if (date === 'today' || !date) date = utcDay(now);
+ if (!await siteOn()) return null;
+ if (!ai && !await aiOn()) return null;
+ const { takes, total } = await takesFor(date);
+ const latest = takes.reduce((n, t) => Math.max(n, Number(t.id) || 0), 0);
+ const stored = await get(date);
+ if (!force && !isDue(stored, { now, total, latest_take_id: latest })) return stored;
+ const sample = sampleTakes(takes);
+ const input = cardInput(date, sample);
+ let out = null;
+ try { out = await (ai || require('../ai/ai-service').structured)('live.rundown.today', input, { meter: { kind: 'rundown_today', role: 'chat', source: 'rundown' } }); }
+ catch { out = null; }
+ const text = cardText(out);
+ if (!text) return null;
+ const card = { status: 'ready', date, ...enrich(text, sample, total), generated_at: now, latest_take_id: latest, take_count: total, take_sample: sample.length };
+ try { await db.setSetting(KEY(date), JSON.stringify(card)); } catch { return null; }
+ return card;
+}
+
+async function tick(now = Date.now()) {
+ const date = utcDay(now);
+ const card = await refresh(date, { now });
+ // refresh() also returns the stored card when the day is not due; only a card written now is news.
+ if (card && Number(card.generated_at) === Number(now)) console.log(`[Rundown] Today card for ${date}: ${card.take_count} take(s), ${card.highlights.length} highlight(s)`);
+ return card;
+}
+
+let _job = null;
+function start() {
+ if (_job || drill.enabled) return;
+ _job = require('../utils/jobs').every('rundown-today', CHECK_MS, () => tick().catch((e) => console.warn('[Rundown] today card:', e.message)), { initialDelayMs: 2 * 60 * 1000, jitterMs: 30 * 1000 });
+ console.log(`[Rundown] Today card job started (checks every ${CHECK_MS / 60000} min, writes at most every ${REFRESH_MS / 60000} min while there are new takes)`);
+}
+function stop() { if (typeof _job === 'function') _job(); _job = null; }
+
+/** The server-rendered card, kit markup: headline, the day in a few sentences, each highlight a link to its take. */
+function ssrHtml(card) {
+ if (!card || card.status !== 'ready' || !card.headline || !card.summary) return '';
+ const rows = (card.highlights || []).map((h) => kit.row({
+ href: h.url || takeUrl(h.stream_id, h.take_id),
+ title: h.line,
+ sub: [h.username, h.topic].filter(Boolean).join(' · '),
+ }));
+ return kit.section({
+ title: "Today's rundown", icon: 'spark', note: 'Written by AI from today’s takes.',
+ body: `<p class="rd-today-headline"><strong>${kit.esc(card.headline)}</strong></p><p class="rd-today-sum">${kit.esc(card.summary)}</p>`
+ + (rows.length ? kit.list(rows, { title: 'Highlights' }) : ''),
+ });
+}
+
+module.exports = {
+ refresh, get, tick, start, stop, ssrHtml, isDue, sampleTakes, cardInput, cardText, takesFor, utcDay,
+ REFRESH_MS, CHECK_MS, MIN_TAKES, MAX_TAKES, KEY,
+};
@@ -524,7 +524,12 @@ async function _rundownMeta(p, query, page = 1) {
@@ -524,7 +524,12 @@ async function _rundownMeta(p, query, page = 1) {
const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
const takes = feed && feed.takes || [];
const takes = feed && feed.takes || [];
const episodes = feed && feed.episodes || [];
const episodes = feed && feed.episodes || [];
- const body = feed ? `<header class="rd-mast"><h1 class="rd-title">The Rundown</h1><p class="rd-tagline">${esc(desc)}</p></header><main class="rd-ssr-feed"><h2 class="rd-h2">Latest takes</h2>${takes.map((s) => `<article class="rd-card rd-take"><p class="rd-meta">${esc(s.user && (s.user.display_name || s.user.username) || '')} · ${esc(s.topic && s.topic.title || '')}</p><h3 class="rd-h3">${esc(s.title)}</h3><p class="rd-what">${esc((s.quotes && s.quotes[0] && s.quotes[0].text) || s.what || '')}</p></article>`).join('')}<h2 class="rd-h2">Episodes</h2>${episodes.map((e) => `<article class="rd-card rd-episode"><h3 class="rd-h3"><a href="/rundown/e/${Number(e.stream_id)}">${esc(e.title)}</a></h3><p class="rd-logline">${esc(e.logline || '')}</p></article>`).join('')}${feed.next_page ? `<a class="rd-next" rel="next" href="/rundown?page=${feed.next_page}">More takes and episodes</a>` : ''}</main>` : null;
+ // The AI Today card (server/rundown/today.js): the day in a few sentences, each highlight a link to its take.
+ let todayHtml = '';
+ if (page === 1) {
+ try { todayHtml = require('../rundown/today').ssrHtml(await require('../rundown/today').get(new Date().toISOString().slice(0, 10))); } catch { todayHtml = ''; }
+ }
+ const body = feed ? `<header class="rd-mast"><h1 class="rd-title">The Rundown</h1><p class="rd-tagline">${esc(desc)}</p></header>${todayHtml}<main class="rd-ssr-feed"><h2 class="rd-h2">Latest takes</h2>${takes.map((s) => `<article class="rd-card rd-take"><p class="rd-meta">${esc(s.user && (s.user.display_name || s.user.username) || '')} · ${esc(s.topic && s.topic.title || '')}</p><h3 class="rd-h3">${esc(s.title)}</h3><p class="rd-what">${esc((s.quotes && s.quotes[0] && s.quotes[0].text) || s.what || '')}</p></article>`).join('')}<h2 class="rd-h2">Episodes</h2>${episodes.map((e) => `<article class="rd-card rd-episode"><h3 class="rd-h3"><a href="/rundown/e/${Number(e.stream_id)}">${esc(e.title)}</a></h3><p class="rd-logline">${esc(e.logline || '')}</p></article>`).join('')}${feed.next_page ? `<a class="rd-next" rel="next" href="/rundown?page=${feed.next_page}">More takes and episodes</a>` : ''}</main>` : null;
return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: page > 1 ? `/rundown?page=${page}` : '/rundown', robots: 'index,follow', rundownBody: body,
return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: page > 1 ? `/rundown?page=${page}` : '/rundown', robots: 'index,follow', rundownBody: body,
jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/rundown'), description: desc }),
jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/rundown'), description: desc }),
...(takes.length ? [seo.jsonLd.itemList('Latest takes', takes.map((s) => ({ name: clean(s.title, 110), url: abs(`/rundown/e/${s.stream_id}#rd-take-${s.id}`) })))] : []),
...(takes.length ? [seo.jsonLd.itemList('Latest takes', takes.map((s) => ({ name: clean(s.title, 110), url: abs(`/rundown/e/${s.stream_id}#rd-take-${s.id}`) })))] : []),
@@ -961,7 +966,11 @@ function render(meta, urlPath) {
@@ -961,7 +966,11 @@ function render(meta, urlPath) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
- if (meta.rundownBody) html = html.replace('<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>', meta.rundownBody);
+ if (meta.rundownBody) {
+ // The fragment's loading state is the shared spinner (an ov-mark since the 2026-11 loading look); replace the
+ // one inside #page-rundown only, since the shell holds the same spinner for other pages.
+ html = html.replace(/(<section id="page-rundown"[^>]*>[\s\S]*?)<div class="loading-spinner"><span class="ov-mark"[^>]*><\/span><\/div>/, (m, pre) => pre + meta.rundownBody);
+ }
// The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,
// The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,
// filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.
// filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.
@@ -18,7 +18,10 @@ const day = js.slice(from, to);
@@ -18,7 +18,10 @@ const day = js.slice(from, to);
assert.ok(day.includes("api('/rundown/day/today?limit=100')"), 'it reads the day endpoint');
assert.ok(day.includes("api('/rundown/day/today?limit=100')"), 'it reads the day endpoint');
assert.ok(!/innerHTML|insertAdjacentHTML|outerHTML/.test(day), 'the day section is built with DOM nodes, never markup strings');
assert.ok(!/innerHTML|insertAdjacentHTML|outerHTML/.test(day), 'the day section is built with DOM nodes, never markup strings');
assert.ok(day.includes("sum.status === 'ready'"), 'the day in short shows only when the summary is ready (off, too few takes: no card)');
assert.ok(day.includes("sum.status === 'ready'"), 'the day in short shows only when the summary is ready (off, too few takes: no card)');
-assert.ok(day.includes('if (!day.items.length && !sentences.length) return;'), 'nothing to show: nothing is drawn');
+assert.ok(day.includes('today: first.today || null') && day.includes("day.today && day.today.status === 'ready'"), 'the AI Today card comes from the day endpoint and shows only when it is ready');
+assert.ok(day.includes('function _rdTodayCard(today)') && day.includes('_rdTodayCard(today)'), 'the Today card is drawn above the moments');
+assert.ok(day.includes('_rdDayLink(h.url, \'\', h.line, false)') && day.includes('h.url'), 'each highlight is a link to its take');
+assert.ok(day.includes('if (!day.items.length && !sentences.length && !today) return;'), 'nothing to show: nothing is drawn');
assert.ok(day.includes("'Written by AI from today\\u2019s takes."), 'the summary says who wrote it');
assert.ok(day.includes("'Written by AI from today\\u2019s takes."), 'the summary says who wrote it');
assert.ok(day.includes('rail.scrollLeft = rail.scrollWidth;'), 'the row opens at the newest moment');
assert.ok(day.includes('rail.scrollLeft = rail.scrollWidth;'), 'the row opens at the newest moment');
assert.ok(/for \(let i = 0; next != null && i < 3; i\+\+\)/.test(day), 'a busy day is read in a bounded number of pages');
assert.ok(/for \(let i = 0; next != null && i < 3; i\+\+\)/.test(day), 'a busy day is read in a bounded number of pages');
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// The Rundown's Today card (server/rundown/today.js): the job is due-based — the first three takes make a card,
+// then at most one write per 30 minutes and only when a take newer than the stored card exists; today's takes are
+// sampled across the day; the stored words pass the public-text filter and the explicit guard; a highlight can only
+// point at a take it was given; the server-rendered card links every highlight to its take's anchor.
+const assert = require('assert');
+const db = require('../server/db/database');
+const today = require('../server/rundown/today');
+
+(async () => {
+ const now = Date.parse('2026-11-11T12:00:00Z');
+ assert.strictEqual(today.utcDay(now), '2026-11-11');
+ assert.strictEqual(today.utcDay(Date.parse('2026-11-11T23:59:59Z')), '2026-11-11');
+
+ // ── Cadence: never before three takes, then 30 minutes apart with a new take ──
+ assert.strictEqual(today.isDue(null, { now, total: 0, latest_take_id: null }), false, 'a day with no takes has no card');
+ assert.strictEqual(today.isDue(null, { now, total: 1, latest_take_id: 9 }), true, 'the first take makes a card');
+ assert.strictEqual(today.isDue({ generated_at: now - 29 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 10 }), false, 'not before 30 minutes');
+ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 9 }), false, 'no new takes, no write');
+ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 10 }), true, 'a new take after 30 minutes');
+ assert.strictEqual(today.isDue({ generated_at: now - 31 * 60000, latest_take_id: 9 }, { now, total: 10, latest_take_id: 8 }), false, 'an older cursor never rewrites');
+
+ // ── Sampling: first and last of the day, no duplicates, up to 30 ──
+ const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, title: `take ${i + 1}` }));
+ const sample = today.sampleTakes(items, 30);
+ assert.strictEqual(sample.length, 30);
+ assert.strictEqual(sample[0].id, 1);
+ assert.strictEqual(sample[29].id, 100);
+ assert.strictEqual(new Set(sample.map((x) => x.id)).size, 30);
+ assert.deepStrictEqual(today.sampleTakes(items.slice(0, 4), 30).map((x) => x.id), [1, 2, 3, 4], 'a small day is kept whole');
+
+ // ── The words are public text: clean, and a card that says something sexual is never stored ──
+ const out = today.cardText({
+ headline: '<b>Fridges and builds</b>',
+ summary: '## The day\n- goosely read about fridges.\nann fixed a build.',
+ highlights: [{ take_id: 1, line: 'The fridge line' }, { take_id: 1, line: 'The same take again' }, { take_id: 2, line: ' **It compiles!** ' }],
+ });
+ assert.deepStrictEqual(out, { headline: 'Fridges and builds', summary: 'goosely read about fridges. ann fixed a build.', highlights: [{ take_id: 1, line: 'The fridge line' }, { take_id: 2, line: 'It compiles!' }] });
+ assert.strictEqual(today.cardText({ headline: 'A night', summary: 'They watched porn.', highlights: [] }), null, 'the explicit guard');
+ assert.strictEqual(today.cardText({ headline: 'No summary', highlights: [] }), null);
+
+ // ── The server-rendered card: kit markup, every highlight a link to its take ──
+ const html = today.ssrHtml({ status: 'ready', headline: 'Fridges & builds', summary: 'A day about fridges and one stubborn compiler.', highlights: [
+ { take_id: 41, line: 'The fridge line', url: '/rundown/e/12#rd-take-41', username: 'goosely', topic: 'Fridges' },
+ { take_id: 42, line: 'It compiles at last', stream_id: 13, username: 'ann', topic: 'Rust builds' },
+ ] });
+ assert.match(html, /class="ovk-section"/);
+ assert.match(html, /Fridges & builds/);
+ assert.match(html, /A day about fridges and one stubborn compiler\./);
+ assert.match(html, /href="\/rundown\/e\/12#rd-take-41"/);
+ assert.match(html, /href="\/rundown\/e\/13#rd-take-42"/);
+ assert.match(html, /The fridge line/);
+ assert.match(html, /goosely/);
+ assert.strictEqual(today.ssrHtml(null), '', 'no card, no markup');
+
+ // ── Stored per day, and read back through the day's API shape ──
+ await db.initDb();
+ const mk = async (username, display) => {
+ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: display || username, stream_key: username.padEnd(32, '0') });
+ const u = await db.get('SELECT id FROM users WHERE username = ?', [username]);
+ await db.ensureChannel(u.id);
+ return u.id;
+ };
+ const goose = await mk('goosely', 'goosely');
+ const streamId = Number((await db.createStream({ user_id: goose, title: 'Tech news', category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
+ const date = '2026-11-11';
+ for (let i = 0; i < 3; i++) {
+ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, score, quotes_json, vod_id, said_at, status)
+ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, 4, '[]', NULL, ?, 'live')`, [streamId, goose, 60 * i, 60 * i + 40, `Take ${i}`, `goosely talked about fridges (${i}).`, i === 1 ? 'Internet fridges are a bad idea.' : null, `${date} 1${i}:00:00`]);
+ }
+ const ids = (await db.all('SELECT id FROM rundown_segments ORDER BY id')).map((r) => Number(r.id));
+ const card = await today.refresh(date, { now, ai: async () => ({
+ headline: 'Fridges and the build',
+ summary: 'goosely spent the morning on hacked fridges. The take was that the internet does not belong in a fridge.',
+ highlights: [{ take_id: ids[1], line: 'The fridge take that started it all.' }, { take_id: 999999, line: 'Not a take of today.' }],
+ }) });
+ assert.ok(card, 'the first card is written');
+ assert.strictEqual(card.status, 'ready');
+ assert.strictEqual(card.take_count, 3);
+ assert.strictEqual(card.highlights.length, 1, 'only a real take becomes a highlight');
+ assert.deepStrictEqual(card.highlights[0], {
+ take_id: ids[1], line: 'The fridge take that started it all.', stream_id: streamId, username: 'goosely', topic: null, url: `/rundown/e/${streamId}#rd-take-${ids[1]}`,
+ });
+ assert.deepStrictEqual(await today.get(date), card, 'the card is stored and read back');
+ assert.strictEqual(await today.get('2026-11-10'), null, 'another day has no card');
+
+ // Not due: the next looks return the same card and never call AI.
+ let calls = 0;
+ const noAi = async () => { calls++; return { headline: 'x', summary: 'y', highlights: [] }; };
+ assert.deepStrictEqual(await today.refresh(date, { now: now + 5 * 60000, ai: noAi }), card);
+ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, score, quotes_json, vod_id, said_at, status)
+ VALUES (?, ?, NULL, 200, 240, 'Take 3', 'goosely kept talking about fridges.', NULL, 4, '[]', NULL, ?, 'live')`, [streamId, goose, `${date} 16:00:00`]);
+ assert.deepStrictEqual(await today.refresh(date, { now: now + 10 * 60000, ai: noAi }), card);
+ assert.strictEqual(calls, 0, 'a new take inside the half hour waits');
+ const rewritten = await today.refresh(date, { now: now + 31 * 60000, ai: async () => ({ headline: 'Fridges, again', summary: 'goosely kept going on the fridges.', highlights: [] }) });
+ assert.strictEqual(calls, 0);
+ assert.strictEqual(rewritten.headline, 'Fridges, again');
+ assert.strictEqual(rewritten.take_count, 4);
+ const again = await today.refresh(date, { now: now + 40 * 60000, ai: noAi });
+ assert.strictEqual(again.headline, 'Fridges, again');
+ assert.strictEqual(calls, 0, 'no new take after the rewrite: no call');
+
+ console.log('rundown today card: cadence, storage, filter and render OK');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -400,6 +400,25 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
@@ -400,6 +400,25 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));
assert.ok(src.includes("app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendShell(res, urlPath)));"));
});
});
+ await check('/rundown: the server render carries the AI Today card, each highlight linked to its take', async () => {
+ const today = require('../server/rundown/today');
+ const orig = today.get;
+ today.get = async () => ({ status: 'ready', date: new Date().toISOString().slice(0, 10), headline: 'Fridges and builds', summary: 'A day about fridges and one stubborn compiler.',
+ highlights: [
+ { take_id: 41, line: 'The fridge line', stream_id: 2981, username: 'goosely', topic: 'Fridges', url: '/rundown/e/2981#rd-take-41' },
+ { take_id: 42, line: 'It compiles at last', stream_id: 2982, username: 'ann', topic: 'Rust builds', url: '/rundown/e/2982#rd-take-42' },
+ ] });
+ try {
+ const r = await get('/rundown');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('Today's rundown'), 'the card title');
+ assert.ok(r.body.includes('Fridges and builds'));
+ assert.ok(r.body.includes('A day about fridges and one stubborn compiler.'));
+ assert.ok(r.body.includes('href="/rundown/e/2981#rd-take-41"'), 'the first highlight links to its take');
+ assert.ok(r.body.includes('href="/rundown/e/2982#rd-take-42"'), 'the second highlight links to its take');
+ } finally { today.get = orig; }
+ });
+
server.close();
server.close();
quiet(failures ? `\n${failures} check(s) failed` : '\nseo ssr: all checks passed');
quiet(failures ? `\n${failures} check(s) failed` : '\nseo ssr: all checks passed');
process.exit(failures ? 1 : 0);
process.exit(failures ? 1 : 0);