openvibers/openvibe.live Commits

The Rundown's Today card: AI-written, take-linked, and on the page (#824)

OP OpenVibers committed 2 days ago · b152382738 · 11 files, +389 −12

* The Rundown's Today card: AI-written, take-linked, and on the page

The day's top card, from OpenVibe.AI's live.rundown.today: a headline, two to four sentences and up
to three highlights, each linked to the take it came from (its anchor on the episode page). A
due-based job checks every five minutes, writes the first card at three takes, then rewrites at most
every 30 minutes and only when a take newer than the stored card arrived; it starts nothing under
LIVE_DRILL. The card is stored per UTC day and served by GET /api/rundown/day/:date and by the page's
server render; the browser draws the same card.

The stored words pass the public-text filter and the explicit guard, and a highlight can only point at
one of the day's takes. The server render also fixes the Rundown body that stopped reaching the shell
when the loading state changed: it now replaces the spinner inside #page-rundown only.

Tests: cadence and sampling (test/rundown-today.test.js), storage and filter, the server render
(test/seo-ssr.test.js) and the browser card (test/rundown-day-front.test.js).

* Log the Today card only when it is written

* The Today card starts at the day's first take

A quiet day still has something to say: one take is enough for the card, which then grows with the
day. The cadence is unchanged (at most every 30 minutes, only when a newer take arrived).

Checked: ov test test/rundown-today.test.js.

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex 5d349146..ae4366f2 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -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, chronological
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.
--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:
ddiff --git a/public/features.json b/public/features.json
iindex 0b66ea27..96fc574c 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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",
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex 70eba01d..721c4563 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -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)));
ddiff --git a/server/db/database.js b/server/db/database.js
iindex c1759a34..f714c88b 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/index.js b/server/index.js
iindex 3f215301..ac08708d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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).
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
iindex 8de95cdc..a56a2773 100644
---- a/server/rundown/routes.js
++++ b/server/rundown/routes.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'); }
ddiff --git a/server/rundown/today.js b/server/rundown/today.js
nnew file mode 100644
iindex 00000000..b8a4c8c1
---- /dev/null
++++ b/server/rundown/today.js
@@@ -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,
++};
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex f3f461f1..bd065812 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -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.
ddiff --git a/test/rundown-day-front.test.js b/test/rundown-day-front.test.js
iindex d05a93e8..ee57bf95 100644
---- a/test/rundown-day-front.test.js
++++ b/test/rundown-day-front.test.js
@@@ -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');
ddiff --git a/test/rundown-today.test.js b/test/rundown-today.test.js
nnew file mode 100644
iindex 00000000..9000d7ed
---- /dev/null
++++ b/test/rundown-today.test.js
@@@ -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 &amp; 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); });
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex db4d66ca..77cf78d5 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -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&#39;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);