openvibers/openvibe.today Commits
Agent Streams S7: the Agents live block, schedules and the digest card (#101)
Changes
@@@ -325,3 +325,35 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }}++++/* Agents live and the agent digest (Agent Streams S7). Their own block: never mixed with people's streams. */++.td-agents-live { margin-top: clamp(28px, 5vw, 48px); }++.td-agents { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }++.td-agent-link { display: flex; flex-direction: column; height: 100%; border: 1px solid var(--ov-border, #2a3140); border-radius: 14px; overflow: hidden; color: inherit; text-decoration: none; background: var(--ov-surface, #121722); }++.td-agent-media { position: relative; display: block; aspect-ratio: 16 / 9; background: #0b0f16; }++.td-agent-media img { width: 100%; height: 100%; object-fit: cover; display: block; }++.td-agent-noimg { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #8b93a7; font-size: .82rem; }++.td-agent-label { position: absolute; left: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; }++.td-agent-viewers { position: absolute; right: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; }++.td-agent-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }++.td-agent-title { font-size: .98rem; line-height: 1.25; }++.td-agent-task, .td-agent-step { font-size: .8rem; opacity: .8; }++.td-agent-step { color: var(--ov-accent, #6ea8fe); }++.td-agent-fold { margin-top: 10px; }++.td-agent-fold > summary { cursor: pointer; padding: 8px 0; color: var(--ov-accent, #6ea8fe); }++.td-agent-planned { margin-top: 18px; }++.td-agent-planned h3, .td-agent-digest h3 { margin: 0 0 8px; font-size: 1rem; }++.td-agent-planned ul, .td-agent-digest ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.td-agent-planned a { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; color: inherit; text-decoration: none; padding: 8px 10px; border: 1px solid var(--ov-border, #2a3140); border-radius: 10px; }++.td-agent-planned time { font-variant-numeric: tabular-nums; font-size: .78rem; opacity: .8; min-width: 8.5rem; }++.td-agent-planned b { font-size: .9rem; }++.td-agent-planned span { font-size: .78rem; opacity: .75; }++.td-agent-digest { margin-top: 18px; padding: 12px 14px; border: 1px solid var(--ov-border, #2a3140); border-radius: 12px; background: var(--ov-surface, #121722); }++.td-agent-digest-counts { margin: 0 0 8px; font-size: .85rem; opacity: .85; }++.td-agent-digest li { display: flex; justify-content: space-between; gap: 10px; font-size: .85rem; }++.td-agent-digest span { opacity: .7; }++@media (max-width: 640px) {++ .td-agents { grid-template-columns: 1fr; }++ .td-agent-planned time { min-width: 0; }++ .td-agent-link, .td-agent-planned a { min-height: 44px; }++}nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * Agent streams on Today (Agent Streams S7, design 2026-11-06 §1 and §3).++ *++ * - Agents live: the public agent streams that are live now, one card each, followed channels first and then the++ * most watched. They are their own section, never mixed into people's streams or the calendar rows.++ * - Scheduled: public agent streams that start in the next 24 hours (the design's window).++ * - Your agents this week: the person's own session reports from OpenVibe.Report, private to them.++ *++ * The public reads never take a person's token: Live's agent list is public. The followed-first order uses Network's++ * follow graph with the person's own token (server/identity/follows.js), and a Network failure only loses the order,++ * never the block. A failure from Live answers empty and `unavailable: true`, so the page still renders.++ */++const DAY = 24 * 60 * 60_000;++++/** Followed first, then the most watched, then the newest. Never a person's stream: only agent cards get here. */++function sortAgents(agents, followed = []) {++ const set = new Set(followed || []);++ return [...(Array.isArray(agents) ? agents : [])]++ .filter((a) => a && a.state === 'live' && a.visibility === 'public')++ .sort((a, b) => (set.has(b.owner) - set.has(a.owner))++ || (Number(b.viewers) || 0) - (Number(a.viewers) || 0)++ || Date.parse(b.started_at || 0) - Date.parse(a.started_at || 0));++}++++function createAgents({ config, follows, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {++ const liveBase = String((config.imports && config.imports.live && config.imports.live.url) || config.live.internalUrl || 'https://openvibe.live').replace(/\/+$/, '');++ const reportBase = String((config.report && config.report.url) || 'https://openvibe.report').replace(/\/+$/, '');++ const timeout = (config.live && config.live.timeoutMs) || 2500;++ const reportTimeout = (config.report && config.report.timeoutMs) || 2500;++++ async function getJson(url, token, ms) {++ const res = await fetchImpl(url, {++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ signal: AbortSignal.timeout(ms),++ });++ if (!res.ok) throw new Error(`${url} ${res.status}`);++ return await res.json().catch(() => null);++ }++++ /** The live public agent streams, followed first. */++ async function live({ subject = null, token = null } = {}) {++ let followed = [];++ let followsUnavailable = false;++ if (subject && token) {++ try {++ const r = await follows.forSubject(subject, token);++ followed = r.follows;++ followsUnavailable = r.unavailable;++ } catch { followsUnavailable = true; }++ }++ try {++ const data = await getJson(`${liveBase}/api/streams/agent?live=1`, null, timeout);++ if (!data || !Array.isArray(data.agents)) throw new Error('agents: no list');++ return { agents: sortAgents(data.agents, followed), unavailable: false, follows_unavailable: followsUnavailable };++ } catch (err) {++ log.warn(`[OpenVibe.Today] agents unavailable: ${(err && err.message) || err}`);++ return { agents: [], unavailable: true, follows_unavailable: followsUnavailable };++ }++ }++++ /** The public agent schedules that start in the next 24 hours. */++ async function schedules() {++ try {++ const data = await getJson(`${liveBase}/api/streams/agent/schedules?public=1`, null, timeout);++ if (!data || !Array.isArray(data.schedules)) throw new Error('schedules: no list');++ const until = now() + DAY;++ return {++ schedules: data.schedules.filter((s) => s && s.state === 'scheduled' && Date.parse(s.starts_at) <= until && Date.parse(s.starts_at) >= now() - 60_000),++ unavailable: false,++ };++ } catch (err) {++ log.warn(`[OpenVibe.Today] agent schedules unavailable: ${(err && err.message) || err}`);++ return { schedules: [], unavailable: true };++ }++ }++++ /** The person's own session reports of the last seven days (never anyone else's). */++ async function digest(token, { days = 7 } = {}) {++ if (!token) return { reports: [], unavailable: false };++ try {++ const data = await getJson(`${reportBase}/api/v1/me/session-reports?days=${Math.min(30, Math.max(1, Number(days) || 7))}`, token, reportTimeout);++ if (!data || !Array.isArray(data.reports)) throw new Error('reports: no list');++ return { reports: data.reports, unavailable: false };++ } catch (err) {++ log.warn(`[OpenVibe.Today] agent digest unavailable: ${(err && err.message) || err}`);++ return { reports: [], unavailable: true };++ }++ }++++ return { live, schedules, digest };++}++++module.exports = { createAgents, sortAgents, DAY };@@@ -39,6 +39,7 @@ const { createPublisher } = require('./events/publisher');const { createFeeds } = require('./feeds');const { createFollows } = require('./identity/follows');const { createAgenda } = require('./agenda');++const { createAgents } = require('./agents');const { createBrief } = require('./brief');const { createImports } = require('./imports');const { createNotifier } = require('./notifications');@@@ -68,6 +69,7 @@ async function createApp(opts = {}) {const feeds = createFeeds({ s });const follows = createFollows({ config, fetchImpl, now: s.now, log });const agenda = createAgenda({ s, follows });++ const agents = createAgents({ config, follows, fetchImpl, now: s.now, log });const brief = createBrief({ config, s, agenda, fetchImpl, log });const notifier = createNotifier({ config, s, fetchImpl, log });const mine = require('./my-reminders').createMyReminders({ s });@@@ -85,7 +87,7 @@ async function createApp(opts = {}) {const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });const ctx = {-- config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,++ config, s, keys, sso, principal, events, feeds, follows, agenda, agents, brief, notifier, reminders, mine, publisher,imports, calendarImports, accountData, accountSend, consumer, log,};@@@ -65,6 +65,12 @@ function load(env = process.env) {scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',},++ // OpenVibe.Report's public API, for "Your agents this week" (the S3 digest card).++ report: {++ url: trim(env.OV_REPORT_URL || 'https://openvibe.report'),++ timeoutMs: Math.max(300, int(env.TODAY_REPORT_TIMEOUT_MS, 2500)),++ },++// OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.live: {internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),@@@ -67,18 +67,26 @@ function eventJsonLd(e, config) {* the Link header advertise it. Nothing here is drawn for a signed-in person.*/function createPublicPageViews(ctx) {-- const { config, s, events } = ctx;++ const { config, s, events, agents } = ctx;const base = () => String(config.baseUrl).replace(/\/+$/, '');-- async function home() {++ async function home(req) {const now = s.now();const HOLIDAYS = ['holiday', 'observance'];++ const viewer = req && req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer : null;const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20, exceptKinds: HOLIDAYS });const holidays = await events.listPublic({ from: new Date(now - DAY).toISOString(), to: new Date(now + 8 * DAY).toISOString(), limit: 200, kinds: ['holiday'] });++ // S7: the agents' own block, never mixed into the calendar rows. A signed-in reader gets their follows'++ // order and their own digest; everyone else gets the same public streams by viewers.++ const [liveAgents, plannedAgents, digest] = await Promise.all([++ agents ? agents.live({ subject: viewer ? viewer.subject : null, token: viewer ? viewer.token : null }) : { agents: [] },++ agents ? agents.schedules() : { schedules: [] },++ viewer && agents ? agents.digest(viewer.token) : { reports: [] },++ ]);return {index: true, cache: PUBLIC_CACHE, agent: true,title: null,-- body: raw(views.home({ upcoming: rows, holidays, now })),++ body: raw(views.home({ upcoming: rows, holidays, now, agents: liveAgents, schedules: plannedAgents, digest })),description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',// Every row on the page is a dated, public event the reader can open: that is exactly one Event node.jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),@@@ -126,7 +134,7 @@ function createPageRoutes(ctx) {// ── Home ─────────────────────────────────────────────────const publicViews = createPublicPageViews(ctx);-- r.get('/', async (req, res) => page(req, res, await publicViews.home()));++ r.get('/', async (req, res) => page(req, res, await publicViews.home(req)));// ── A channel's schedule ────────────────────────────────r.get('/@:username', async (req, res) => {@@@ -130,7 +130,44 @@ function holidaysWeek(rows, now) {}).join('')}</ul></li>`).join('')}</ol></section>`;}--function home({ upcoming = [], holidays = [], now = Date.now() } = {}) {++++/** One live agent card: its last frame, the task and the current step (S7). Every value is text. */++function agentCard(a) {++ const step = a.current_step && a.current_step.title ? `<span class="td-agent-step">${esc(a.current_step.title)}</span>` : '';++ const viewers = a.viewers ? `<span class="td-agent-viewers">${esc(String(a.viewers))} watching</span>` : '';++ return `<li class="td-agent"><a class="td-agent-link" href="${esc(a.page_url || '#')}">++<span class="td-agent-media">${a.last_frame_url ? `<img src="${esc(a.last_frame_url)}" alt="" loading="lazy">` : '<span class="td-agent-noimg">No frame yet</span>'}<span class="td-agent-label">AI agent</span>${viewers}</span>++<span class="td-agent-body"><b class="td-agent-title">${esc(a.title || 'An AI agent stream')}</b>++${a.task && a.task.title ? `<span class="td-agent-task">${esc(a.task.title)}</span>` : ''}${step}</span></a></li>`;++}++++/** The Agents live block: public live agent streams (followed first, then most watched), the next 24 hours of++ * schedules, and — for the person signed in — the digest card of their own agents. Never mixed with people's streams. */++function agentsLive({ agents = null, schedules = null, digest = null, now = Date.now() } = {}) {++ const live = (agents && Array.isArray(agents.agents)) ? agents.agents : [];++ const planned = (schedules && Array.isArray(schedules.schedules)) ? schedules.schedules : [];++ const reports = (digest && Array.isArray(digest.reports)) ? digest.reports : [];++ if (!live.length && !planned.length && !reports.length) return '';++ const shown = live.slice(0, 3);++ const rest = live.slice(3);++ const cards = shown.length ? `<ul class="td-agents">${shown.map(agentCard).join('')}</ul>` : '';++ // The counted fold: never a silent cut, and it works without JavaScript.++ const more = rest.length ? `<details class="td-agent-fold"><summary>More (${rest.length})</summary><ul class="td-agents">${rest.map(agentCard).join('')}</ul></details>` : '';++ const plannedRows = planned.slice(0, 4).map((s) => `<li><a href="${esc(s.page_url || '#')}"><time datetime="${esc(s.starts_at)}" data-local="time">${esc(parts(s.starts_at, 'UTC').month)} ${esc(parts(s.starts_at, 'UTC').day)}, ${esc(timeText(parts(s.starts_at, 'UTC')))}</time><b>${esc(s.title)}</b><span>@${esc(s.channel)}${s.waits_for_go ? ' · waits for Go' : ''}</span></a></li>`).join('');++ const plannedBlock = plannedRows ? `<div class="td-agent-planned"><h3>Scheduled · next 24 hours</h3><ul>${plannedRows}</ul></div>` : '';++ let digestBlock = '';++ if (reports.length) {++ const done = reports.filter((r) => r.outcome === 'done').length;++ const failed = reports.filter((r) => r.outcome === 'failed').length;++ const links = reports.slice(0, 3).map((r) => `<li><a href="https://openvibe.report/s/${encodeURIComponent(r.id)}">${esc((r.task && r.task.title) || r.summary || 'A session')}</a><span>${esc(r.outcome || 'done')}</span></li>`).join('');++ digestBlock = `<aside class="td-agent-digest" aria-labelledby="td-agent-digest-h"><h3 id="td-agent-digest-h">Your agents this week</h3>++<p class="td-agent-digest-counts"><b>${reports.length}</b> session${reports.length === 1 ? '' : 's'}${done ? ` · ${done} done` : ''}${failed ? ` · ${failed} failed` : ''}</p><ul>${links}</ul></aside>`;++ }++ return `<section class="td-agents-live" id="td-agents" aria-labelledby="td-agents-h"><div class="td-head-row"><h2 id="td-agents-h">${kit.icon('live')}Agents live</h2><a class="td-more" href="https://openvibe.live/">On Live${kit.icon('chevron')}</a></div>++${cards}${more}${plannedBlock}${digestBlock}</section>`;++}++++function home({ upcoming = [], holidays = [], agents = null, schedules = null, digest = null, now = Date.now() } = {}) {const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));const live = upcoming.filter((e) => e.status === 'live');const scenes = [@@@ -182,7 +219,8 @@ ${days(upcoming, { now })}</section>` : '';['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],] });-- const body = hero.replace(/<\/div>\s*$/, `${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);++ const agentsHTML = agentsLive({ agents, schedules, digest, now });++ const body = hero.replace(/<\/div>\s*$/, `${agentsHTML}${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);return `<div class="td-wrap" id="td-home">${body}</div>`;}nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// Agent streams on Today (S7): only public live agent streams, followed first, never mixed with people's streams,++// the counted fold after three, the next 24 hours of schedules, and the digest card of the person's own agents.++const assert = require('assert');++const { createAgents, sortAgents } = require('../server/agents');++const views = require('../server/views');++++let failures = 0;++function check(name, fn) {++ try { fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }++}++++const A = {++ id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNA1', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', channel: 'ann',++ title: 'Followed agent', state: 'live', visibility: 'public', viewers: 2,++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the login page' }, current_step: { title: 'Run the tests', status: 'started' },++ started_at: '2026-11-07T01:00:00Z', page_url: 'https://openvibe.live/@ann/agents/ags_01JAB2C3D4E5F6G7H8J9K0MNA1',++};++const B = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNB2', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2', channel: 'bob', title: 'Watched agent', viewers: 40, started_at: '2026-11-07T00:00:00Z' };++const C = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNC3', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3', channel: 'cat', title: 'A small agent', viewers: 1, started_at: '2026-11-07T02:00:00Z' };++const D = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MND4', title: 'A private agent', visibility: 'private' };++const E = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNE5', title: 'An ended agent', state: 'ended' };++++check('only public live streams, followed first and then the most watched', () => {++ const out = sortAgents([B, C, A, D, E], [A.owner]);++ assert.deepStrictEqual(out.map((a) => a.title), ['Followed agent', 'Watched agent', 'A small agent']);++});++++check('the Agents live block is its own section, with a counted fold after three', () => {++ const html = views.home({++ now: Date.now(),++ agents: { agents: [A, B, C, { ...D, title: 'Fourth', visibility: 'public', viewers: 0 }, { ...E, title: 'Fifth', state: 'live', viewers: 0 }] },++ schedules: { schedules: [] },++ digest: { reports: [] },++ });++ assert.match(html, /id="td-agents"/);++ assert.match(html, /Agents live/);++ assert.match(html, /More \(2\)/);++ assert.match(html, /Followed agent/);++ assert.match(html, /AI agent/);++ // The calendar's own rows never carry an agent: the block is added beside them, not mixed into them.++ assert.ok(!/<li class="td-ev[^"]*">[^]*?Followed agent/.test(html), 'an agent is not an event row');++});++++check('a schedule in the next 24 hours shows with its time; the digest is the person\'s own', () => {++ const html = views.home({++ now: Date.now(),++ agents: { agents: [] },++ schedules: { schedules: [{ id: 'scd_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Nightly update', channel: 'ann', starts_at: new Date(Date.now() + 3600_000).toISOString(), state: 'scheduled', waits_for_go: false, page_url: 'https://openvibe.live/@ann/agents' }] },++ digest: { reports: [{ id: 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS', task: { title: 'My agent fixed the login page' }, outcome: 'done' }] },++ });++ assert.match(html, /Scheduled · next 24 hours/);++ assert.match(html, /Nightly update/);++ assert.match(html, /Your agents this week/);++ assert.match(html, /My agent fixed the login page/);++});++++check('createAgents filters to 24 hours and followed first, and a Live failure is an empty block', async () => {++ const calls = [];++ const fetchImpl = async (url) => {++ calls.push(url);++ if (String(url).includes('/api/streams/agent?live=1')) return { ok: true, json: async () => ({ agents: [B, A, D] }) };++ if (String(url).includes('/schedules?public=1')) {++ return { ok: true, json: async () => ({ schedules: [++ { id: 'scd_1', title: 'Soon', state: 'scheduled', starts_at: new Date(Date.now() + 3600_000).toISOString() },++ { id: 'scd_2', title: 'Later', state: 'scheduled', starts_at: new Date(Date.now() + 25 * 3600_000).toISOString() },++ ] }) };++ }++ throw new Error('unexpected ' + url);++ };++ const agents = createAgents({++ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 1000, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 1000 } },++ follows: { forSubject: async () => ({ follows: [A.owner], unavailable: false }) },++ fetchImpl, log: { warn() {} },++ });++ const live = await agents.live({ subject: A.owner, token: 'person-token' });++ assert.deepStrictEqual(live.agents.map((a) => a.title), ['Followed agent', 'Watched agent']);++ assert.strictEqual(live.agents.length, 2, 'the private one is not on Today');++ const scheduled = await agents.schedules();++ assert.deepStrictEqual(scheduled.schedules.map((s) => s.title), ['Soon'], 'only the next 24 hours');++++ const down = createAgents({++ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 100, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 100 } },++ follows: { forSubject: async () => ({ follows: [], unavailable: true }) },++ fetchImpl: async () => { throw new Error('down'); }, log: { warn() {} },++ });++ assert.deepStrictEqual(await down.live(), { agents: [], unavailable: true, follows_unavailable: false });++});++++check('the phone layout keeps the cards one column and the tap targets 44 px', () => {++ const fs = require('node:fs');++ const path = require('node:path');++ const css = fs.readFileSync(path.join(__dirname, '../public/css/today.css'), 'utf8');++ assert.match(css, /@media \(max-width: 640px\)[\s\S]*td-agents \{ grid-template-columns: 1fr/);++ assert.match(css, /min-height: 44px/);++});++++console.log(failures ? `\n${failures} check(s) failed` : '\nAll Today S7 checks passed');++process.exit(failures ? 1 : 0);
@@ -325,3 +325,35 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
@@ -325,3 +325,35 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
}
+
+/* Agents live and the agent digest (Agent Streams S7). Their own block: never mixed with people's streams. */
+.td-agents-live { margin-top: clamp(28px, 5vw, 48px); }
+.td-agents { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
+.td-agent-link { display: flex; flex-direction: column; height: 100%; border: 1px solid var(--ov-border, #2a3140); border-radius: 14px; overflow: hidden; color: inherit; text-decoration: none; background: var(--ov-surface, #121722); }
+.td-agent-media { position: relative; display: block; aspect-ratio: 16 / 9; background: #0b0f16; }
+.td-agent-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.td-agent-noimg { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: #8b93a7; font-size: .82rem; }
+.td-agent-label { position: absolute; left: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; }
+.td-agent-viewers { position: absolute; right: 8px; bottom: 8px; padding: 2px 7px; border-radius: 999px; background: rgba(4, 10, 20, .8); color: #fff; font-size: .7rem; }
+.td-agent-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
+.td-agent-title { font-size: .98rem; line-height: 1.25; }
+.td-agent-task, .td-agent-step { font-size: .8rem; opacity: .8; }
+.td-agent-step { color: var(--ov-accent, #6ea8fe); }
+.td-agent-fold { margin-top: 10px; }
+.td-agent-fold > summary { cursor: pointer; padding: 8px 0; color: var(--ov-accent, #6ea8fe); }
+.td-agent-planned { margin-top: 18px; }
+.td-agent-planned h3, .td-agent-digest h3 { margin: 0 0 8px; font-size: 1rem; }
+.td-agent-planned ul, .td-agent-digest ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.td-agent-planned a { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; color: inherit; text-decoration: none; padding: 8px 10px; border: 1px solid var(--ov-border, #2a3140); border-radius: 10px; }
+.td-agent-planned time { font-variant-numeric: tabular-nums; font-size: .78rem; opacity: .8; min-width: 8.5rem; }
+.td-agent-planned b { font-size: .9rem; }
+.td-agent-planned span { font-size: .78rem; opacity: .75; }
+.td-agent-digest { margin-top: 18px; padding: 12px 14px; border: 1px solid var(--ov-border, #2a3140); border-radius: 12px; background: var(--ov-surface, #121722); }
+.td-agent-digest-counts { margin: 0 0 8px; font-size: .85rem; opacity: .85; }
+.td-agent-digest li { display: flex; justify-content: space-between; gap: 10px; font-size: .85rem; }
+.td-agent-digest span { opacity: .7; }
+@media (max-width: 640px) {
+ .td-agents { grid-template-columns: 1fr; }
+ .td-agent-planned time { min-width: 0; }
+ .td-agent-link, .td-agent-planned a { min-height: 44px; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * Agent streams on Today (Agent Streams S7, design 2026-11-06 §1 and §3).
+ *
+ * - Agents live: the public agent streams that are live now, one card each, followed channels first and then the
+ * most watched. They are their own section, never mixed into people's streams or the calendar rows.
+ * - Scheduled: public agent streams that start in the next 24 hours (the design's window).
+ * - Your agents this week: the person's own session reports from OpenVibe.Report, private to them.
+ *
+ * The public reads never take a person's token: Live's agent list is public. The followed-first order uses Network's
+ * follow graph with the person's own token (server/identity/follows.js), and a Network failure only loses the order,
+ * never the block. A failure from Live answers empty and `unavailable: true`, so the page still renders.
+ */
+const DAY = 24 * 60 * 60_000;
+
+/** Followed first, then the most watched, then the newest. Never a person's stream: only agent cards get here. */
+function sortAgents(agents, followed = []) {
+ const set = new Set(followed || []);
+ return [...(Array.isArray(agents) ? agents : [])]
+ .filter((a) => a && a.state === 'live' && a.visibility === 'public')
+ .sort((a, b) => (set.has(b.owner) - set.has(a.owner))
+ || (Number(b.viewers) || 0) - (Number(a.viewers) || 0)
+ || Date.parse(b.started_at || 0) - Date.parse(a.started_at || 0));
+}
+
+function createAgents({ config, follows, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
+ const liveBase = String((config.imports && config.imports.live && config.imports.live.url) || config.live.internalUrl || 'https://openvibe.live').replace(/\/+$/, '');
+ const reportBase = String((config.report && config.report.url) || 'https://openvibe.report').replace(/\/+$/, '');
+ const timeout = (config.live && config.live.timeoutMs) || 2500;
+ const reportTimeout = (config.report && config.report.timeoutMs) || 2500;
+
+ async function getJson(url, token, ms) {
+ const res = await fetchImpl(url, {
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ signal: AbortSignal.timeout(ms),
+ });
+ if (!res.ok) throw new Error(`${url} ${res.status}`);
+ return await res.json().catch(() => null);
+ }
+
+ /** The live public agent streams, followed first. */
+ async function live({ subject = null, token = null } = {}) {
+ let followed = [];
+ let followsUnavailable = false;
+ if (subject && token) {
+ try {
+ const r = await follows.forSubject(subject, token);
+ followed = r.follows;
+ followsUnavailable = r.unavailable;
+ } catch { followsUnavailable = true; }
+ }
+ try {
+ const data = await getJson(`${liveBase}/api/streams/agent?live=1`, null, timeout);
+ if (!data || !Array.isArray(data.agents)) throw new Error('agents: no list');
+ return { agents: sortAgents(data.agents, followed), unavailable: false, follows_unavailable: followsUnavailable };
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] agents unavailable: ${(err && err.message) || err}`);
+ return { agents: [], unavailable: true, follows_unavailable: followsUnavailable };
+ }
+ }
+
+ /** The public agent schedules that start in the next 24 hours. */
+ async function schedules() {
+ try {
+ const data = await getJson(`${liveBase}/api/streams/agent/schedules?public=1`, null, timeout);
+ if (!data || !Array.isArray(data.schedules)) throw new Error('schedules: no list');
+ const until = now() + DAY;
+ return {
+ schedules: data.schedules.filter((s) => s && s.state === 'scheduled' && Date.parse(s.starts_at) <= until && Date.parse(s.starts_at) >= now() - 60_000),
+ unavailable: false,
+ };
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] agent schedules unavailable: ${(err && err.message) || err}`);
+ return { schedules: [], unavailable: true };
+ }
+ }
+
+ /** The person's own session reports of the last seven days (never anyone else's). */
+ async function digest(token, { days = 7 } = {}) {
+ if (!token) return { reports: [], unavailable: false };
+ try {
+ const data = await getJson(`${reportBase}/api/v1/me/session-reports?days=${Math.min(30, Math.max(1, Number(days) || 7))}`, token, reportTimeout);
+ if (!data || !Array.isArray(data.reports)) throw new Error('reports: no list');
+ return { reports: data.reports, unavailable: false };
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] agent digest unavailable: ${(err && err.message) || err}`);
+ return { reports: [], unavailable: true };
+ }
+ }
+
+ return { live, schedules, digest };
+}
+
+module.exports = { createAgents, sortAgents, DAY };
@@ -39,6 +39,7 @@ const { createPublisher } = require('./events/publisher');
@@ -39,6 +39,7 @@ const { createPublisher } = require('./events/publisher');
const { createFeeds } = require('./feeds');
const { createFeeds } = require('./feeds');
const { createFollows } = require('./identity/follows');
const { createFollows } = require('./identity/follows');
const { createAgenda } = require('./agenda');
const { createAgenda } = require('./agenda');
+const { createAgents } = require('./agents');
const { createBrief } = require('./brief');
const { createBrief } = require('./brief');
const { createImports } = require('./imports');
const { createImports } = require('./imports');
const { createNotifier } = require('./notifications');
const { createNotifier } = require('./notifications');
@@ -68,6 +69,7 @@ async function createApp(opts = {}) {
@@ -68,6 +69,7 @@ async function createApp(opts = {}) {
const feeds = createFeeds({ s });
const feeds = createFeeds({ s });
const follows = createFollows({ config, fetchImpl, now: s.now, log });
const follows = createFollows({ config, fetchImpl, now: s.now, log });
const agenda = createAgenda({ s, follows });
const agenda = createAgenda({ s, follows });
+ const agents = createAgents({ config, follows, fetchImpl, now: s.now, log });
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
const mine = require('./my-reminders').createMyReminders({ s });
const mine = require('./my-reminders').createMyReminders({ s });
@@ -85,7 +87,7 @@ async function createApp(opts = {}) {
@@ -85,7 +87,7 @@ async function createApp(opts = {}) {
const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
const ctx = {
const ctx = {
- config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,
+ config, s, keys, sso, principal, events, feeds, follows, agenda, agents, brief, notifier, reminders, mine, publisher,
imports, calendarImports, accountData, accountSend, consumer, log,
imports, calendarImports, accountData, accountSend, consumer, log,
};
};
@@ -65,6 +65,12 @@ function load(env = process.env) {
@@ -65,6 +65,12 @@ function load(env = process.env) {
scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',
scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',
},
},
+ // OpenVibe.Report's public API, for "Your agents this week" (the S3 digest card).
+ report: {
+ url: trim(env.OV_REPORT_URL || 'https://openvibe.report'),
+ timeoutMs: Math.max(300, int(env.TODAY_REPORT_TIMEOUT_MS, 2500)),
+ },
+
// OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.
// OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.
live: {
live: {
internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
@@ -67,18 +67,26 @@ function eventJsonLd(e, config) {
@@ -67,18 +67,26 @@ function eventJsonLd(e, config) {
* the Link header advertise it. Nothing here is drawn for a signed-in person.
* the Link header advertise it. Nothing here is drawn for a signed-in person.
*/
*/
function createPublicPageViews(ctx) {
function createPublicPageViews(ctx) {
- const { config, s, events } = ctx;
+ const { config, s, events, agents } = ctx;
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const base = () => String(config.baseUrl).replace(/\/+$/, '');
- async function home() {
+ async function home(req) {
const now = s.now();
const now = s.now();
const HOLIDAYS = ['holiday', 'observance'];
const HOLIDAYS = ['holiday', 'observance'];
+ const viewer = req && req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer : null;
const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20, exceptKinds: HOLIDAYS });
const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20, exceptKinds: HOLIDAYS });
const holidays = await events.listPublic({ from: new Date(now - DAY).toISOString(), to: new Date(now + 8 * DAY).toISOString(), limit: 200, kinds: ['holiday'] });
const holidays = await events.listPublic({ from: new Date(now - DAY).toISOString(), to: new Date(now + 8 * DAY).toISOString(), limit: 200, kinds: ['holiday'] });
+ // S7: the agents' own block, never mixed into the calendar rows. A signed-in reader gets their follows'
+ // order and their own digest; everyone else gets the same public streams by viewers.
+ const [liveAgents, plannedAgents, digest] = await Promise.all([
+ agents ? agents.live({ subject: viewer ? viewer.subject : null, token: viewer ? viewer.token : null }) : { agents: [] },
+ agents ? agents.schedules() : { schedules: [] },
+ viewer && agents ? agents.digest(viewer.token) : { reports: [] },
+ ]);
return {
return {
index: true, cache: PUBLIC_CACHE, agent: true,
index: true, cache: PUBLIC_CACHE, agent: true,
title: null,
title: null,
- body: raw(views.home({ upcoming: rows, holidays, now })),
+ body: raw(views.home({ upcoming: rows, holidays, now, agents: liveAgents, schedules: plannedAgents, digest })),
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
// Every row on the page is a dated, public event the reader can open: that is exactly one Event node.
// Every row on the page is a dated, public event the reader can open: that is exactly one Event node.
jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),
jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),
@@ -126,7 +134,7 @@ function createPageRoutes(ctx) {
@@ -126,7 +134,7 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
const publicViews = createPublicPageViews(ctx);
const publicViews = createPublicPageViews(ctx);
- r.get('/', async (req, res) => page(req, res, await publicViews.home()));
+ r.get('/', async (req, res) => page(req, res, await publicViews.home(req)));
// ── A channel's schedule ────────────────────────────────
// ── A channel's schedule ────────────────────────────────
r.get('/@:username', async (req, res) => {
r.get('/@:username', async (req, res) => {
@@ -130,7 +130,44 @@ function holidaysWeek(rows, now) {
@@ -130,7 +130,44 @@ function holidaysWeek(rows, now) {
}).join('')}</ul></li>`).join('')}</ol></section>`;
}).join('')}</ul></li>`).join('')}</ol></section>`;
}
}
-function home({ upcoming = [], holidays = [], now = Date.now() } = {}) {
+
+/** One live agent card: its last frame, the task and the current step (S7). Every value is text. */
+function agentCard(a) {
+ const step = a.current_step && a.current_step.title ? `<span class="td-agent-step">${esc(a.current_step.title)}</span>` : '';
+ const viewers = a.viewers ? `<span class="td-agent-viewers">${esc(String(a.viewers))} watching</span>` : '';
+ return `<li class="td-agent"><a class="td-agent-link" href="${esc(a.page_url || '#')}">
+<span class="td-agent-media">${a.last_frame_url ? `<img src="${esc(a.last_frame_url)}" alt="" loading="lazy">` : '<span class="td-agent-noimg">No frame yet</span>'}<span class="td-agent-label">AI agent</span>${viewers}</span>
+<span class="td-agent-body"><b class="td-agent-title">${esc(a.title || 'An AI agent stream')}</b>
+${a.task && a.task.title ? `<span class="td-agent-task">${esc(a.task.title)}</span>` : ''}${step}</span></a></li>`;
+}
+
+/** The Agents live block: public live agent streams (followed first, then most watched), the next 24 hours of
+ * schedules, and — for the person signed in — the digest card of their own agents. Never mixed with people's streams. */
+function agentsLive({ agents = null, schedules = null, digest = null, now = Date.now() } = {}) {
+ const live = (agents && Array.isArray(agents.agents)) ? agents.agents : [];
+ const planned = (schedules && Array.isArray(schedules.schedules)) ? schedules.schedules : [];
+ const reports = (digest && Array.isArray(digest.reports)) ? digest.reports : [];
+ if (!live.length && !planned.length && !reports.length) return '';
+ const shown = live.slice(0, 3);
+ const rest = live.slice(3);
+ const cards = shown.length ? `<ul class="td-agents">${shown.map(agentCard).join('')}</ul>` : '';
+ // The counted fold: never a silent cut, and it works without JavaScript.
+ const more = rest.length ? `<details class="td-agent-fold"><summary>More (${rest.length})</summary><ul class="td-agents">${rest.map(agentCard).join('')}</ul></details>` : '';
+ const plannedRows = planned.slice(0, 4).map((s) => `<li><a href="${esc(s.page_url || '#')}"><time datetime="${esc(s.starts_at)}" data-local="time">${esc(parts(s.starts_at, 'UTC').month)} ${esc(parts(s.starts_at, 'UTC').day)}, ${esc(timeText(parts(s.starts_at, 'UTC')))}</time><b>${esc(s.title)}</b><span>@${esc(s.channel)}${s.waits_for_go ? ' · waits for Go' : ''}</span></a></li>`).join('');
+ const plannedBlock = plannedRows ? `<div class="td-agent-planned"><h3>Scheduled · next 24 hours</h3><ul>${plannedRows}</ul></div>` : '';
+ let digestBlock = '';
+ if (reports.length) {
+ const done = reports.filter((r) => r.outcome === 'done').length;
+ const failed = reports.filter((r) => r.outcome === 'failed').length;
+ const links = reports.slice(0, 3).map((r) => `<li><a href="https://openvibe.report/s/${encodeURIComponent(r.id)}">${esc((r.task && r.task.title) || r.summary || 'A session')}</a><span>${esc(r.outcome || 'done')}</span></li>`).join('');
+ digestBlock = `<aside class="td-agent-digest" aria-labelledby="td-agent-digest-h"><h3 id="td-agent-digest-h">Your agents this week</h3>
+<p class="td-agent-digest-counts"><b>${reports.length}</b> session${reports.length === 1 ? '' : 's'}${done ? ` · ${done} done` : ''}${failed ? ` · ${failed} failed` : ''}</p><ul>${links}</ul></aside>`;
+ }
+ return `<section class="td-agents-live" id="td-agents" aria-labelledby="td-agents-h"><div class="td-head-row"><h2 id="td-agents-h">${kit.icon('live')}Agents live</h2><a class="td-more" href="https://openvibe.live/">On Live${kit.icon('chevron')}</a></div>
+${cards}${more}${plannedBlock}${digestBlock}</section>`;
+}
+
+function home({ upcoming = [], holidays = [], agents = null, schedules = null, digest = null, now = Date.now() } = {}) {
const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));
const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));
const live = upcoming.filter((e) => e.status === 'live');
const live = upcoming.filter((e) => e.status === 'live');
const scenes = [
const scenes = [
@@ -182,7 +219,8 @@ ${days(upcoming, { now })}</section>` : '';
@@ -182,7 +219,8 @@ ${days(upcoming, { now })}</section>` : '';
['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],
['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],
['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],
['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],
] });
] });
- const body = hero.replace(/<\/div>\s*$/, `${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);
+ const agentsHTML = agentsLive({ agents, schedules, digest, now });
+ const body = hero.replace(/<\/div>\s*$/, `${agentsHTML}${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);
return `<div class="td-wrap" id="td-home">${body}</div>`;
return `<div class="td-wrap" id="td-home">${body}</div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// Agent streams on Today (S7): only public live agent streams, followed first, never mixed with people's streams,
+// the counted fold after three, the next 24 hours of schedules, and the digest card of the person's own agents.
+const assert = require('assert');
+const { createAgents, sortAgents } = require('../server/agents');
+const views = require('../server/views');
+
+let failures = 0;
+function check(name, fn) {
+ try { fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
+}
+
+const A = {
+ id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNA1', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', channel: 'ann',
+ title: 'Followed agent', state: 'live', visibility: 'public', viewers: 2,
+ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the login page' }, current_step: { title: 'Run the tests', status: 'started' },
+ started_at: '2026-11-07T01:00:00Z', page_url: 'https://openvibe.live/@ann/agents/ags_01JAB2C3D4E5F6G7H8J9K0MNA1',
+};
+const B = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNB2', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2', channel: 'bob', title: 'Watched agent', viewers: 40, started_at: '2026-11-07T00:00:00Z' };
+const C = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNC3', owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNC3', channel: 'cat', title: 'A small agent', viewers: 1, started_at: '2026-11-07T02:00:00Z' };
+const D = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MND4', title: 'A private agent', visibility: 'private' };
+const E = { ...A, id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNE5', title: 'An ended agent', state: 'ended' };
+
+check('only public live streams, followed first and then the most watched', () => {
+ const out = sortAgents([B, C, A, D, E], [A.owner]);
+ assert.deepStrictEqual(out.map((a) => a.title), ['Followed agent', 'Watched agent', 'A small agent']);
+});
+
+check('the Agents live block is its own section, with a counted fold after three', () => {
+ const html = views.home({
+ now: Date.now(),
+ agents: { agents: [A, B, C, { ...D, title: 'Fourth', visibility: 'public', viewers: 0 }, { ...E, title: 'Fifth', state: 'live', viewers: 0 }] },
+ schedules: { schedules: [] },
+ digest: { reports: [] },
+ });
+ assert.match(html, /id="td-agents"/);
+ assert.match(html, /Agents live/);
+ assert.match(html, /More \(2\)/);
+ assert.match(html, /Followed agent/);
+ assert.match(html, /AI agent/);
+ // The calendar's own rows never carry an agent: the block is added beside them, not mixed into them.
+ assert.ok(!/<li class="td-ev[^"]*">[^]*?Followed agent/.test(html), 'an agent is not an event row');
+});
+
+check('a schedule in the next 24 hours shows with its time; the digest is the person\'s own', () => {
+ const html = views.home({
+ now: Date.now(),
+ agents: { agents: [] },
+ schedules: { schedules: [{ id: 'scd_01JAB2C3D4E5F6G7H8J9K0MNPS', title: 'Nightly update', channel: 'ann', starts_at: new Date(Date.now() + 3600_000).toISOString(), state: 'scheduled', waits_for_go: false, page_url: 'https://openvibe.live/@ann/agents' }] },
+ digest: { reports: [{ id: 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS', task: { title: 'My agent fixed the login page' }, outcome: 'done' }] },
+ });
+ assert.match(html, /Scheduled · next 24 hours/);
+ assert.match(html, /Nightly update/);
+ assert.match(html, /Your agents this week/);
+ assert.match(html, /My agent fixed the login page/);
+});
+
+check('createAgents filters to 24 hours and followed first, and a Live failure is an empty block', async () => {
+ const calls = [];
+ const fetchImpl = async (url) => {
+ calls.push(url);
+ if (String(url).includes('/api/streams/agent?live=1')) return { ok: true, json: async () => ({ agents: [B, A, D] }) };
+ if (String(url).includes('/schedules?public=1')) {
+ return { ok: true, json: async () => ({ schedules: [
+ { id: 'scd_1', title: 'Soon', state: 'scheduled', starts_at: new Date(Date.now() + 3600_000).toISOString() },
+ { id: 'scd_2', title: 'Later', state: 'scheduled', starts_at: new Date(Date.now() + 25 * 3600_000).toISOString() },
+ ] }) };
+ }
+ throw new Error('unexpected ' + url);
+ };
+ const agents = createAgents({
+ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 1000, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 1000 } },
+ follows: { forSubject: async () => ({ follows: [A.owner], unavailable: false }) },
+ fetchImpl, log: { warn() {} },
+ });
+ const live = await agents.live({ subject: A.owner, token: 'person-token' });
+ assert.deepStrictEqual(live.agents.map((a) => a.title), ['Followed agent', 'Watched agent']);
+ assert.strictEqual(live.agents.length, 2, 'the private one is not on Today');
+ const scheduled = await agents.schedules();
+ assert.deepStrictEqual(scheduled.schedules.map((s) => s.title), ['Soon'], 'only the next 24 hours');
+
+ const down = createAgents({
+ config: { imports: { live: { url: 'https://live.test' } }, live: { timeoutMs: 100, internalUrl: 'https://live.test' }, report: { url: 'https://report.test', timeoutMs: 100 } },
+ follows: { forSubject: async () => ({ follows: [], unavailable: true }) },
+ fetchImpl: async () => { throw new Error('down'); }, log: { warn() {} },
+ });
+ assert.deepStrictEqual(await down.live(), { agents: [], unavailable: true, follows_unavailable: false });
+});
+
+check('the phone layout keeps the cards one column and the tap targets 44 px', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const css = fs.readFileSync(path.join(__dirname, '../public/css/today.css'), 'utf8');
+ assert.match(css, /@media \(max-width: 640px\)[\s\S]*td-agents \{ grid-template-columns: 1fr/);
+ assert.match(css, /min-height: 44px/);
+});
+
+console.log(failures ? `\n${failures} check(s) failed` : '\nAll Today S7 checks passed');
+process.exit(failures ? 1 : 0);