openvibers/openvibe.report Commits

Agent Streams S7: chapter links, topic citations and the third-party report door (#112)

OP OpenVibers committed 7 days ago · 3ab0d9ced0 · 6 files, +176 −5

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 7995bbc..9de705c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -251,6 +251,29 @@ async function createApp(opts = {}) {
// ── Loopback-only operator and Events routes ────────────
app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));
++ // ── A session report from any harness with the person's own token (Agent Streams S7) ──
++ // openvibe-sdk/reports writes here. The Report service takes the owner from the token, never from the body; the
++ // report is private by default; a browser call must be same-origin, while a server-to-server call carries a Bearer.
++ app.post('/api/v1/me/session-reports', express.json({ limit: '256kb' }), async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const viewer = sharing.viewerOf(req.user);
++ if (!viewer) return contracts.http.sendProblem(res, 401, 'auth.sign_in_required', { detail: 'Sign in to write your report.', ctx: req.ov });
++ const bearer = /^Bearer\s+\S+/i.test(String(req.headers.authorization || ''));
++ const sameSite = ['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''));
++ if (!bearer && !sameSite && !req.headers['x-requested-with']) {
++ return contracts.http.sendProblem(res, 403, 'share.csrf', { detail: 'Cross-site request refused', ctx: req.ov });
++ }
++ if (!sessionReports) return contracts.http.sendProblem(res, 503, 'report.no_database', { detail: 'Report has no database in this process.', ctx: req.ov });
++ const body = req.body && typeof req.body === 'object' ? { ...req.body } : {};
++ delete body.owner;
++ try {
++ const out = await sessionReports.write({ ...body, owner: viewer.subject.id });
++ return res.status(out.created ? 201 : 200).json(out);
++ } catch (err) {
++ return contracts.http.sendProblem(res, 400, 'report.invalid', { detail: String((err && err.message) || err).slice(0, 300), ctx: req.ov });
++ }
++ });
++
// ── API: CORS-open, public for two minutes, 120 requests a minute per address ──
app.use('/api/v1', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7d08c50..d213392 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -124,6 +124,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return res.json(recaps);
});
++ // The caller's own session reports of the last days (Today's "Your agents this week", S7). The list is the
++ // person's alone: a signed-out reader is 401, never a list of everybody's public reports.
++ r.get('/me/session-reports', async (req, res) => {
++ if (!sessionReports) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
++ const viewer = sharing.viewerOf(req.user);
++ if (!viewer) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to read your agents.');
++ const days = Math.min(30, Math.max(1, Number(req.query.days) || 7));
++ const from = now() - days * 24 * 60 * 60_000;
++ const reports = (await sessionReports.listForOwner(viewer.subject.id, { limit: 200 }))
++ .filter((r) => { const at = Date.parse(r.created_at); return !Number.isFinite(at) || at >= from; });
++ return res.json({ days, reports });
++ });
++
// ── Session reports (Agent Streams S3), in the sharing system's four audiences ─────────────────────
//
// The share row decides through canView, exactly as the page does: a report of Specific people is a 404 for a
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 6073c6d..5b74ed1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -186,7 +186,9 @@ function createPageRoutes(ctx) {
if (!isSlug(slug)) return notFound(req, res);
const topic = await live.topic(slug);
if (!topic) return notFound(req, res);
-- return send(req, res, views.topic({ topic }));
++ // S7: a public agent report may cite the topic when the agent worked on it (only Everyone's reports).
++ const citations = sessionReports ? await sessionReports.listPublicMatchingTopic((topic.topic && topic.topic.title) || topic.title).catch(() => []) : [];
++ return send(req, res, views.topic({ topic, citations }));
});
r.get('/rundown/e/:streamId', async (req, res) => {
ddiff --git a/server/session-reports.js b/server/session-reports.js
iindex d8f0c42..6eb6770 100644
---- a/server/session-reports.js
++++ b/server/session-reports.js
@@@ -306,6 +306,23 @@ function createSessionReports({ store, config, log = console, sharing = null })
return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);
}
++ /**
++ * The public reports whose task says the agent worked on this topic (S7). Only Everyone's reports are read
++ * (listPublic is the sharing system's public answer), and the topic's whole name must appear as a whole phrase:
++ * "login" is not "login page", and a name inside another word is never a match.
++ */
++ async function listPublicMatchingTopic(title, { limit = 6 } = {}) {
++ const phrase = normalizeTopic(title);
++ if (!phrase) return [];
++ const all = await listPublic({ limit: 500 });
++ const re = new RegExp(`(^| )${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}( |$)`);
++ return all.filter((r) => {
++ const text = [r.task && r.task.title, r.summary, ...(Array.isArray(r.highlights) ? r.highlights : [])]
++ .map(normalizeTopic).filter(Boolean).join(' ');
++ return re.test(text);
++ }).slice(0, Math.max(1, Math.min(20, Number(limit) || 6)));
++ }
++
/** A person's own reports, newest first (their page and their default's "apply to all"). */
async function listForOwner(owner, { limit = 24 } = {}) {
if (!db || !owner) return [];
@@@ -337,8 +354,13 @@ function createSessionReports({ store, config, log = console, sharing = null })
return {
write, writeStream, get, getWithAudience, listPublic, listPublicToday, listForOwner, attachStream, urlFor,
-- defaultAudience, setDefaultAudience, startingAudience,
++ defaultAudience, setDefaultAudience, startingAudience, listPublicMatchingTopic,
};
}
--module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };
\\ No newline at end of file
++/** A topic or a report's text as words: lower case, accents folded, punctuation and markup gone. */
++function normalizeTopic(value) {
++ return String(value || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, ' ').trim();
++}
++
++module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, normalizeTopic, ID_RE };
\\ No newline at end of file
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 2e6ce9b..ad98cdd 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -450,21 +450,43 @@ function sharingChip(audience, members = []) {
return 'Only you';
}
--function topic({ topic: d } = {}) {
++function topic({ topic: d, citations = [] } = {}) {
const t = d.topic || {};
const takes = (d.takes || []).slice(0, 40);
++ // S7: a public agent report whose task worked on this topic, under the whole-name rule. Only Everyone's reports
++ // reach here (the page asks listPublicMatchingTopic); a hidden report is never a citation.
++ const cites = (Array.isArray(citations) ? citations : []).slice(0, 6);
++ const citesHTML = cites.length ? `<section aria-labelledby="rp-cite-h"><h2 id="rp-cite-h">Agents that worked on this</h2><ul class="rp-topics">${cites.map((r) => `<li class="rp-report"><a href="/s/${encodeURIComponent(r.id)}"><span class="rp-kind">${esc((r.outcome || 'done') === 'failed' ? 'Failed' : (r.outcome || 'done') === 'stopped' ? 'Stopped' : 'Done')}</span><b>${esc((r.task && r.task.title) || 'An agent session')}</b><span class="rp-report-s">${esc(r.summary || '')}</span><span class="rp-meta">${AI}${r.worked_seconds ? ` · ${esc(worked(r))}` : ''}</span></a></li>`).join('')}</ul></section>` : '';
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>
${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>
${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}
++${citesHTML}
${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;
return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });
}
--function episode({ episode: d } = {}) {
++/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */
++function chapterHref(vodId, sec) {
++ const id = String(vodId == null ? '' : vodId).trim();
++ if (!/^\d{1,16}$/.test(id)) return null;
++ const t = Math.max(0, Math.floor(Number(sec) || 0));
++ return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;
++}
++
++function episode({ episode: d, citations = [] } = {}) {
const e = d.episode || {}, s = d.stream || {};
const segs = (d.segments || []).slice(0, 60);
++ // S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.
++ const vod = e.vod_id || s.vod_id || null;
++ const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);
++ const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {
++ const href = vod ? chapterHref(vod, c.sec) : null;
++ const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;
++ return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;
++ }).join('')}</ol></section>` : '';
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}
<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>
++${chaptersHTML}
${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}
${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
ddiff --git a/test/agent-reports.test.js b/test/agent-reports.test.js
nnew file mode 100644
iindex 0000000..affd271
---- /dev/null
++++ b/test/agent-reports.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * Report, S7: chapter links and topic citations, and the third-party report door.
++ *
++ * - an episode's chapters link into OpenVibe.Video at their second, from the episode's own vod_id;
++ * - a topic page cites a public agent report when the agent worked on the topic's whole name, never a hidden report
++ * and never a partial name;
++ * - POST /api/v1/me/session-reports writes with the person's token: the owner comes from the token, never the body,
++ * and the report is private by default; GET /api/v1/me/session-reports is only that person's.
++ */
++const assert = require('assert');
++const express = require('express');
++const { boot, check, done } = require('./helpers/boot');
++const views = require('../server/views');
++
++const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';
++const OTHER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPT';
++
++const sso = {
++ router: () => express.Router(),
++ optionalAuth: () => (req, _res, next) => {
++ if (req.headers['x-test-user'] === 'owner') req.user = { subject_id: OWNER, role: 'user', username: 'ann' };
++ next();
++ },
++};
++const verifyServiceToken = async (token) => (token === 'good'
++ ? { ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } }
++ : { ok: false, code: 'token.invalid' });
++const report = (over = {}) => ({
++ owner: OWNER, source: 'codes', task: { ref: 'tsk_' + Math.random().toString(36).slice(2), title: 'Fix the checkout page' },
++ summary: 'The agent fixed the checkout page and ran the tests.', visibility: 'public', ...over,
++});
++
++(async () => {
++ const t = await boot({ db: true, sso, verifyServiceToken, now: () => Date.parse('2026-11-07T12:00:00Z') });
++ try {
++ await check('the third-party door takes the owner from the token, not the body', async () => {
++ const r = await t.get('/api/v1/me/session-reports', {
++ json: { ...report({ visibility: 'private' }), owner: OTHER, summary: 'Mine, with a body owner that must lose.' },
++ headers: { 'x-test-user': 'owner', 'sec-fetch-site': 'same-origin' },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ const stored = await t.ctx.sessionReports.get(r.json().id);
++ assert.strictEqual(stored.owner, OWNER, 'the body owner never wins');
++ assert.strictEqual(stored.visibility, 'private', 'private by default');
++ });
++
++ await check('signed out is a 401, and the own list is only that person\'s', async () => {
++ const anon = await t.get('/api/v1/me/session-reports', { json: report() });
++ assert.strictEqual(anon.status, 401);
++ await t.get('/internal/session-reports', { json: report({ owner: OTHER, visibility: 'public' }), headers: { authorization: 'Bearer good' } });
++ const mine = await t.get('/api/v1/me/session-reports?days=7', { headers: { 'x-test-user': 'owner' } });
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.ok(mine.json().reports.every((r) => r.owner === OWNER), 'never another person\'s report');
++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('a public report cites a topic by its whole name; a private one is never a citation', async () => {
++ const wrote = await t.get('/internal/session-reports', {
++ json: report({ task: { ref: 'tsk_login_page_1', title: 'Fix the login page' }, summary: 'The login page redirects again.', visibility: 'public' }),
++ headers: { authorization: 'Bearer good' },
++ });
++ assert.strictEqual(wrote.status, 201, wrote.text);
++ await t.get('/internal/session-reports', {
++ json: report({ task: { ref: 'tsk_private_1', title: 'Fix the login page privately' }, visibility: 'private' }),
++ headers: { authorization: 'Bearer good' },
++ });
++ const matches = await t.ctx.sessionReports.listPublicMatchingTopic('Login page');
++ assert.ok(matches.length >= 1, 'the public report is cited');
++ assert.ok(matches.every((r) => /login page/i.test(r.task.title + ' ' + r.summary)), 'every citation speaks of the topic');
++ assert.ok(!matches.some((r) => /privately/.test(r.task.title)), 'a private report is never cited');
++ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('login pag'), [], 'a partial name is not the topic');
++ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('ogin'), [], 'a name inside a word is not the topic');
++ });
++
++ await check('the topic page draws the citations and the episode draws its chapters into the recording', async () => {
++ const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });
++ assert.match(topic, /Agents that worked on this/);
++ assert.match(topic, /Fix the login page/);
++ const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });
++ assert.match(episode, /https:\/\/openvibe\.video\/v\/4242\?t=95/);
++ assert.match(episode, /01:35/);
++ const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });
++ assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');
++ });
++
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e.stack || String(e)); process.exit(1); });