openvibers/openvibe.report Commits
Agent layer: /.well-known/agent.json and Markdown twins of the numbers, AI boards and a model page (#7)
Changes
@@@ -15,7 +15,7 @@"helmet": "^8.1.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.1"},"devDependencies": {@@@ -25,6 +25,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.13","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",@@@ -108,6 +114,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -188,6 +207,39 @@"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -216,6 +268,73 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -266,6 +385,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -524,6 +655,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -626,6 +861,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",@@@ -752,6 +1011,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -825,12 +1100,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1246,6 +1522,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -21,7 +21,7 @@"helmet": "^8.1.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.1"},"engines": {nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openvibe.report tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves: its JSON API, its API page, and the crawl files it publishes.++// No OpenAPI document and no MCP endpoint exist for Report, so neither is named.++module.exports = {++ id: 'report',++ name: 'OpenVibe.Report',++ purpose: 'The network\'s open numbers: every OpenVibe site\'s stats, The Rundown of what streamers talked about, each stream\'s after-show report, and the AI benchmark boards.',++ home: 'https://openvibe.report',++ site: 'openvibe.report',++ api: '/api/v1',++ docs: '/developers',++ policy: {++ auth: ['none'],++ rateLimit: '120 API requests a minute per address',++ },++};nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++// The Markdown twins of openvibe.report's public pages (openvibe-shared/agent-layer). Each is rendered by the same++// data and view the route uses (createPublicPages in http/pages.js), so a twin cannot drift from its page; the mount++// sits after the page routes, so the pages themselves keep their own cache policy, and twinPath here is also what++// marks the pages that advertise a twin.++const { normaliseDays } = require('./data/source');++++const twinPath = (path) => /^\/(?:|stats|developers|ai|ai\/[^/]+|ai\/models\/[^/]+)$/.test(String(path || '/'));++++function createAgentPages({ publicPages, bench }) {++ async function renderPublicPage(path, { query } = {}) {++ let page = null;++ const model = /^\/ai\/models\/([^/]+)$/.exec(path);++ const kind = /^\/ai\/([^/]+)$/.exec(path);++ if (path === '/') page = await publicPages.home();++ else if (path === '/developers') page = publicPages.developers();++ else if (path === '/stats') page = await publicPages.stats(normaliseDays(query && query.get('days')));++ else if (path === '/ai') page = await publicPages.aiIndex();++ else if (model) page = await publicPages.aiModel(decodeURIComponent(model[1]));++ else if (kind) page = await publicPages.aiBoard(kind[1], bench.normaliseTab(query && query.get('tab')));++ return page ? { html: page.html } : null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages, twinPath };@@@ -31,7 +31,7 @@ const { createBenchClient } = require('./data/bench');const { createAnalytics } = require('./analytics');const { createShadow } = require('./analytics/shadow');const { createApi } = require('./http/api');--const { createPageRoutes } = require('./http/pages');++const { createPageRoutes, createPublicPages } = require('./http/pages');const { createDiscoveryRoutes } = require('./http/discovery');const { createInternalRoutes } = require('./http/internal');const { createServiceReadiness } = require('./observability');@@@ -143,7 +143,15 @@ async function createApp(opts = {}) {// ── Crawl artifacts, then the pages ─────────────────────app.use(createDiscoveryRoutes(ctx));-- app.use(createPageRoutes(ctx));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and each twinned page's Markdown twin. It++ // mounts after the page routes, which keep their own cache policy and answer the HTML (with the discovery links),++ // and before the 404, so an unknown twin is still a 404. A .md path skips the page routes (http/pages.js).++ const publicPages = createPublicPages(ctx);++ app.use(createPageRoutes({ ...ctx, publicPages }));++ require('openvibe-shared/agent-layer').mount(app, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages({ publicPages, bench }).renderPublicPage,++ });app.use((_req, res) => {res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());});@@@ -40,6 +40,7 @@ function createDiscoveryRoutes({ config, live, bench }) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', CACHE).send('# openvibe.report: the public pages are for search and AI crawlers; the JSON API is not.\n'++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/'] }),);});@@@ -14,111 +14,149 @@** A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public* for two minutes.++ *++ * The public pages the agent layer also twins have their data and view here (createPublicPages), so a route and its++ * Markdown twin render the very same page; `agent: true` in those views adds the discovery links.*/const express = require('express');const views = require('../views');const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');++const { twinPath } = require('../agent-pages');const CACHE = 'public, max-age=120';--function createPageRoutes({ network, live, bench, analytics = null }) {-- const r = express.Router();++/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */++function createPublicPages({ network, live, bench, analytics = null }) {++ const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));-- function send(res, html, status = 200, cacheControl = CACHE) {-- res.status(status).set('Cache-Control', cacheControl).type('html').send(html);++ async function home() {++ const [stats, rundown, topics] = await Promise.all([numbers(7), live.rundown(), live.topics()]);++ // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.++ return { html: views.home({ stats, rundown, topics }) };++ }++++ async function stats(days) {++ const data = await numbers(days);++ return data ? { html: views.stats({ stats: data, days }) } : null;++ }++++ function developers() {++ return { html: views.developers() };}-- function notFound(res) {-- return send(res, views.notFound(), 404);++++ async function aiIndex() {++ const kinds = await bench.kinds();++ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];++ const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));++ return { html: views.aiIndex({ kinds, boards }) };}-- r.get('/', async (_req, res) => {-- const [stats, rundown, topics] = await Promise.all([-- analytics ? analytics.stats(7) : network.stats(7),-- live.rundown(),-- live.topics(),++ async function aiBoard(kindId, tab) {++ if (!bench.isKindId(kindId)) return null;++ const [kinds, board, cost] = await Promise.all([++ bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null),]);-- // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.-- return send(res, views.home({ stats, rundown, topics }));-- });++ const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;++ if (kinds && !kind) return null;++ return { html: views.aiBoard({ kindId, kind, tab, board, cost, kinds }) };++ }-- r.get('/developers', (_req, res) => send(res, views.developers()));++ async function aiModel(model) {++ if (!bench.isModelName(model)) return null;++ return { html: views.aiModel({ model, summary: await bench.modelSummary(model) }) };++ }++++ return { home, stats, developers, aiIndex, aiBoard, aiModel };++}++++function createPageRoutes(ctx) {++ const { live, bench } = ctx;++ const r = express.Router();++ const publicPages = ctx.publicPages || createPublicPages(ctx);++ // A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.++ r.use((req, res, next) => (req.path.endsWith('.md') ? next('router') : next()));++++ function send(req, res, html, status = 200, cacheControl = CACHE) {++ // A page with a Markdown twin names it, as the agent layer's own responses do (openvibe-shared/agent-layer).++ if (status === 200 && twinPath(req.path)) {++ const site = String(ctx.config.baseUrl).replace(/\/+$/, '');++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${req.originalUrl}`, `${site}/llms.txt`).header);++ }++ res.status(status).set('Cache-Control', cacheControl).type('html').send(html);++ }++ function notFound(req, res) {++ return send(req, res, views.notFound(), 404);++ }++++ r.get('/', async (req, res) => send(req, res, (await publicPages.home()).html));++++ r.get('/developers', (req, res) => send(req, res, publicPages.developers().html));r.get('/stats', async (req, res) => {-- const days = normaliseDays(req.query.days);-- const stats = analytics ? await analytics.stats(days) : await network.stats(days);-- if (!stats) return notFound(res);-- return send(res, views.stats({ stats, days }));++ const page = await publicPages.stats(normaliseDays(req.query.days));++ if (!page) return notFound(req, res);++ return send(req, res, page.html);});-- r.get('/ai', async (_req, res) => {-- const kinds = await bench.kinds();-- const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];-- const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));-- return send(res, views.aiIndex({ kinds, boards }), 200, 'no-store');-- });++ r.get('/ai', async (req, res) => send(req, res, (await publicPages.aiIndex()).html, 200, 'no-store'));r.get('/ai/models/:model', async (req, res) => {-- const model = req.params.model;-- if (!bench.isModelName(model)) return notFound(res);-- const summary = await bench.modelSummary(model);-- return send(res, views.aiModel({ model, summary }), 200, 'no-store');++ const page = await publicPages.aiModel(req.params.model);++ if (!page) return notFound(req, res);++ return send(req, res, page.html, 200, 'no-store');});r.get('/ai/:kind', async (req, res) => {-- const kindId = req.params.kind;-- if (!bench.isKindId(kindId)) return notFound(res);-- const tab = bench.normaliseTab(req.query.tab);-- const [kinds, board, cost] = await Promise.all([bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null)]);-- const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;-- if (kinds && !kind) return notFound(res);-- return send(res, views.aiBoard({ kindId, kind, tab, board, cost, kinds }), 200, 'no-store');++ const page = await publicPages.aiBoard(req.params.kind, bench.normaliseTab(req.query.tab));++ if (!page) return notFound(req, res);++ return send(req, res, page.html, 200, 'no-store');});-- r.get('/rundown', async (_req, res) => {++ r.get('/rundown', async (req, res) => {const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);-- if (!rundown || !topics) return notFound(res);-- return send(res, views.rundown({ rundown, topics }));++ if (!rundown || !topics) return notFound(req, res);++ return send(req, res, views.rundown({ rundown, topics }));});r.get('/rundown/t/:slug', async (req, res) => {const { slug } = req.params;-- if (!isSlug(slug)) return notFound(res);++ if (!isSlug(slug)) return notFound(req, res);const topic = await live.topic(slug);-- if (!topic) return notFound(res);-- return send(res, views.topic({ topic }));++ if (!topic) return notFound(req, res);++ return send(req, res, views.topic({ topic }));});r.get('/rundown/e/:streamId', async (req, res) => {-- if (!isStreamId(req.params.streamId)) return notFound(res);++ if (!isStreamId(req.params.streamId)) return notFound(req, res);const episode = await live.episode(streamIdOf(req.params.streamId));-- if (!episode) return notFound(res);-- return send(res, views.episode({ episode }));++ if (!episode) return notFound(req, res);++ return send(req, res, views.episode({ episode }));});r.get('/rundown/s/:username', async (req, res) => {const { username } = req.params;-- if (!isUsername(username)) return notFound(res);++ if (!isUsername(username)) return notFound(req, res);const speaker = await live.speaker(username);-- if (!speaker) return notFound(res);-- return send(res, views.speaker({ speaker, username }));++ if (!speaker) return notFound(req, res);++ return send(req, res, views.speaker({ speaker, username }));});r.get('/after-show/:streamId', async (req, res) => {-- if (!isStreamId(req.params.streamId)) return notFound(res);++ if (!isStreamId(req.params.streamId)) return notFound(req, res);const recap = await live.recap(streamIdOf(req.params.streamId));-- if (!recap) return notFound(res);-- return send(res, views.recap({ recap }));++ if (!recap) return notFound(req, res);++ return send(req, res, views.recap({ recap }));});r.get('/c/:username', async (req, res) => {const { username } = req.params;-- if (!isUsername(username)) return notFound(res);++ if (!isUsername(username)) return notFound(req, res);const recaps = await live.channelRecaps(username);-- if (!recaps) return notFound(res);-- return send(res, views.channel({ username, recaps }));++ if (!recaps) return notFound(req, res);++ return send(req, res, views.channel({ username, recaps }));});return r;}--module.exports = { createPageRoutes };++module.exports = { createPageRoutes, createPublicPages };@@@ -77,7 +77,7 @@ const NAV = [{ label: 'API', href: '/developers', icon: 'fa-code', more: true },];--function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [] }) {++function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false }) {canonical = canonical || (path ? canon(path) : undefined);const navbar = {service: 'report',@@@ -95,6 +95,9 @@ function layout({ title, description, canonical = null, path = null, robots = 'isiteName: NAME,description: description || 'The network\'s numbers open to everyone, what streamers talked about in The Rundown, and every stream\'s after-show report.',canonical,++ // A page with a Markdown twin (server/agent-pages.js) names it in the head; only twinned pages set agent.++ agentLayer: agent,++ llms: `${BASE}/llms.txt`,robots,jsonLd,home: '/',@@@ -222,7 +225,7 @@ function home({ stats, rundown, topics } = {}) {['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],] },});-- return layout({ title: null, body, active: '/', path: '/' });++ return layout({ title: null, body, active: '/', path: '/', agent: true });}function stats({ stats: d, days = 30 } = {}) {@@@ -249,7 +252,7 @@ ${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site<${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;-- return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}` });++ return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });}function rundown({ rundown: R = {}, topics } = {}) {@@@ -341,7 +344,7 @@ function developers() {const body = `<header class="rp-page-h"><p class="rp-kicker">For developers</p><h1>The Report API</h1><p class="rp-lede">Open JSON, no key, readable from any site (CORS). Counts only, never about a person.</p></header><section aria-labelledby="rp-ep-h"><h2 id="rp-ep-h">Endpoints</h2><dl class="rp-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section><p class="rp-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;-- return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });++ return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers', agent: true });}function benchState(...reads) {@@@ -376,7 +379,7 @@ function aiIndex({ kinds, boards = [] }) {+ `<p class="rp-meta rp-ai-links"><a href="${href}">Elo</a><span aria-hidden="true">·</span><a href="${href}?tab=pareto">Pareto</a><span aria-hidden="true">·</span><a href="${href}?tab=cost">Cost per solved</a></p></li>`;}).join('')}</ul></section>`).join('');const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;-- return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai' });++ return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai', agent: true });}function aiTable(headers, rows) {@@@ -423,7 +426,7 @@ function aiBoard({ kindId, kind, tab, board, cost, kinds }) {if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;-- return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId) });++ return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId), agent: true });}function aiModel({ model, summary }) {@@@ -437,7 +440,7 @@ function aiModel({ model, summary }) {return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;});const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;-- return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model) });++ return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model), agent: true });}function notFound() {
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -25,6 +25,12 @@
@@ -25,6 +25,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.13",
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
@@ -108,6 +114,19 @@
@@ -108,6 +114,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -188,6 +207,39 @@
@@ -188,6 +207,39 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -216,6 +268,73 @@
@@ -216,6 +268,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -266,6 +385,18 @@
@@ -266,6 +385,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -524,6 +655,110 @@
@@ -524,6 +655,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -626,6 +861,30 @@
@@ -626,6 +861,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -752,6 +1011,22 @@
@@ -752,6 +1011,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -825,12 +1100,13 @@
@@ -825,12 +1100,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1246,6 +1522,12 @@
@@ -1246,6 +1522,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openvibe.report tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves: its JSON API, its API page, and the crawl files it publishes.
+// No OpenAPI document and no MCP endpoint exist for Report, so neither is named.
+module.exports = {
+ id: 'report',
+ name: 'OpenVibe.Report',
+ purpose: 'The network\'s open numbers: every OpenVibe site\'s stats, The Rundown of what streamers talked about, each stream\'s after-show report, and the AI benchmark boards.',
+ home: 'https://openvibe.report',
+ site: 'openvibe.report',
+ api: '/api/v1',
+ docs: '/developers',
+ policy: {
+ auth: ['none'],
+ rateLimit: '120 API requests a minute per address',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+// The Markdown twins of openvibe.report's public pages (openvibe-shared/agent-layer). Each is rendered by the same
+// data and view the route uses (createPublicPages in http/pages.js), so a twin cannot drift from its page; the mount
+// sits after the page routes, so the pages themselves keep their own cache policy, and twinPath here is also what
+// marks the pages that advertise a twin.
+const { normaliseDays } = require('./data/source');
+
+const twinPath = (path) => /^\/(?:|stats|developers|ai|ai\/[^/]+|ai\/models\/[^/]+)$/.test(String(path || '/'));
+
+function createAgentPages({ publicPages, bench }) {
+ async function renderPublicPage(path, { query } = {}) {
+ let page = null;
+ const model = /^\/ai\/models\/([^/]+)$/.exec(path);
+ const kind = /^\/ai\/([^/]+)$/.exec(path);
+ if (path === '/') page = await publicPages.home();
+ else if (path === '/developers') page = publicPages.developers();
+ else if (path === '/stats') page = await publicPages.stats(normaliseDays(query && query.get('days')));
+ else if (path === '/ai') page = await publicPages.aiIndex();
+ else if (model) page = await publicPages.aiModel(decodeURIComponent(model[1]));
+ else if (kind) page = await publicPages.aiBoard(kind[1], bench.normaliseTab(query && query.get('tab')));
+ return page ? { html: page.html } : null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages, twinPath };
@@ -31,7 +31,7 @@ const { createBenchClient } = require('./data/bench');
@@ -31,7 +31,7 @@ const { createBenchClient } = require('./data/bench');
const { createAnalytics } = require('./analytics');
const { createAnalytics } = require('./analytics');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
-const { createPageRoutes } = require('./http/pages');
+const { createPageRoutes, createPublicPages } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createInternalRoutes } = require('./http/internal');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
@@ -143,7 +143,15 @@ async function createApp(opts = {}) {
@@ -143,7 +143,15 @@ async function createApp(opts = {}) {
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
app.use(createDiscoveryRoutes(ctx));
app.use(createDiscoveryRoutes(ctx));
- app.use(createPageRoutes(ctx));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and each twinned page's Markdown twin. It
+ // mounts after the page routes, which keep their own cache policy and answer the HTML (with the discovery links),
+ // and before the 404, so an unknown twin is still a 404. A .md path skips the page routes (http/pages.js).
+ const publicPages = createPublicPages(ctx);
+ app.use(createPageRoutes({ ...ctx, publicPages }));
+ require('openvibe-shared/agent-layer').mount(app, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages({ publicPages, bench }).renderPublicPage,
+ });
app.use((_req, res) => {
app.use((_req, res) => {
res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());
res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());
});
});
@@ -40,6 +40,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
@@ -40,6 +40,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', CACHE).send(
res.type('text/plain').set('Cache-Control', CACHE).send(
'# openvibe.report: the public pages are for search and AI crawlers; the JSON API is not.\n'
'# openvibe.report: the public pages are for search and AI crawlers; the JSON API is not.\n'
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/'] }),
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/'] }),
);
);
});
});
@@ -14,111 +14,149 @@
@@ -14,111 +14,149 @@
*
*
* A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public
* A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public
* for two minutes.
* for two minutes.
+ *
+ * The public pages the agent layer also twins have their data and view here (createPublicPages), so a route and its
+ * Markdown twin render the very same page; `agent: true` in those views adds the discovery links.
*/
*/
const express = require('express');
const express = require('express');
const views = require('../views');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
+const { twinPath } = require('../agent-pages');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createPageRoutes({ network, live, bench, analytics = null }) {
- const r = express.Router();
+/** The pages the agent layer twins: their data, then the view. Each returns { html } or null (nothing to show). */
+function createPublicPages({ network, live, bench, analytics = null }) {
+ const numbers = (days) => (analytics ? analytics.stats(days) : network.stats(days));
- function send(res, html, status = 200, cacheControl = CACHE) {
- res.status(status).set('Cache-Control', cacheControl).type('html').send(html);
+ async function home() {
+ const [stats, rundown, topics] = await Promise.all([numbers(7), live.rundown(), live.topics()]);
+ // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.
+ return { html: views.home({ stats, rundown, topics }) };
+ }
+
+ async function stats(days) {
+ const data = await numbers(days);
+ return data ? { html: views.stats({ stats: data, days }) } : null;
+ }
+
+ function developers() {
+ return { html: views.developers() };
}
}
- function notFound(res) {
- return send(res, views.notFound(), 404);
+
+ async function aiIndex() {
+ const kinds = await bench.kinds();
+ const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
+ const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));
+ return { html: views.aiIndex({ kinds, boards }) };
}
}
- r.get('/', async (_req, res) => {
- const [stats, rundown, topics] = await Promise.all([
- analytics ? analytics.stats(7) : network.stats(7),
- live.rundown(),
- live.topics(),
+ async function aiBoard(kindId, tab) {
+ if (!bench.isKindId(kindId)) return null;
+ const [kinds, board, cost] = await Promise.all([
+ bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null),
]);
]);
- // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.
- return send(res, views.home({ stats, rundown, topics }));
- });
+ const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
+ if (kinds && !kind) return null;
+ return { html: views.aiBoard({ kindId, kind, tab, board, cost, kinds }) };
+ }
- r.get('/developers', (_req, res) => send(res, views.developers()));
+ async function aiModel(model) {
+ if (!bench.isModelName(model)) return null;
+ return { html: views.aiModel({ model, summary: await bench.modelSummary(model) }) };
+ }
+
+ return { home, stats, developers, aiIndex, aiBoard, aiModel };
+}
+
+function createPageRoutes(ctx) {
+ const { live, bench } = ctx;
+ const r = express.Router();
+ const publicPages = ctx.publicPages || createPublicPages(ctx);
+ // A path ending in .md is a Markdown twin (server/agent-pages.js), never a page parameter.
+ r.use((req, res, next) => (req.path.endsWith('.md') ? next('router') : next()));
+
+ function send(req, res, html, status = 200, cacheControl = CACHE) {
+ // A page with a Markdown twin names it, as the agent layer's own responses do (openvibe-shared/agent-layer).
+ if (status === 200 && twinPath(req.path)) {
+ const site = String(ctx.config.baseUrl).replace(/\/+$/, '');
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${req.originalUrl}`, `${site}/llms.txt`).header);
+ }
+ res.status(status).set('Cache-Control', cacheControl).type('html').send(html);
+ }
+ function notFound(req, res) {
+ return send(req, res, views.notFound(), 404);
+ }
+
+ r.get('/', async (req, res) => send(req, res, (await publicPages.home()).html));
+
+ r.get('/developers', (req, res) => send(req, res, publicPages.developers().html));
r.get('/stats', async (req, res) => {
r.get('/stats', async (req, res) => {
- const days = normaliseDays(req.query.days);
- const stats = analytics ? await analytics.stats(days) : await network.stats(days);
- if (!stats) return notFound(res);
- return send(res, views.stats({ stats, days }));
+ const page = await publicPages.stats(normaliseDays(req.query.days));
+ if (!page) return notFound(req, res);
+ return send(req, res, page.html);
});
});
- r.get('/ai', async (_req, res) => {
- const kinds = await bench.kinds();
- const list = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds : [];
- const boards = await Promise.all(list.map((kind) => bench.board(kind.id, 'elo')));
- return send(res, views.aiIndex({ kinds, boards }), 200, 'no-store');
- });
+ r.get('/ai', async (req, res) => send(req, res, (await publicPages.aiIndex()).html, 200, 'no-store'));
r.get('/ai/models/:model', async (req, res) => {
r.get('/ai/models/:model', async (req, res) => {
- const model = req.params.model;
- if (!bench.isModelName(model)) return notFound(res);
- const summary = await bench.modelSummary(model);
- return send(res, views.aiModel({ model, summary }), 200, 'no-store');
+ const page = await publicPages.aiModel(req.params.model);
+ if (!page) return notFound(req, res);
+ return send(req, res, page.html, 200, 'no-store');
});
});
r.get('/ai/:kind', async (req, res) => {
r.get('/ai/:kind', async (req, res) => {
- const kindId = req.params.kind;
- if (!bench.isKindId(kindId)) return notFound(res);
- const tab = bench.normaliseTab(req.query.tab);
- const [kinds, board, cost] = await Promise.all([bench.kinds(), bench.board(kindId, tab), tab === 'pareto' ? bench.board(kindId, 'cost') : Promise.resolve(null)]);
- const kind = kinds && Array.isArray(kinds.data.kinds) ? kinds.data.kinds.find((k) => k.id === kindId) : null;
- if (kinds && !kind) return notFound(res);
- return send(res, views.aiBoard({ kindId, kind, tab, board, cost, kinds }), 200, 'no-store');
+ const page = await publicPages.aiBoard(req.params.kind, bench.normaliseTab(req.query.tab));
+ if (!page) return notFound(req, res);
+ return send(req, res, page.html, 200, 'no-store');
});
});
- r.get('/rundown', async (_req, res) => {
+ r.get('/rundown', async (req, res) => {
const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
- if (!rundown || !topics) return notFound(res);
- return send(res, views.rundown({ rundown, topics }));
+ if (!rundown || !topics) return notFound(req, res);
+ return send(req, res, views.rundown({ rundown, topics }));
});
});
r.get('/rundown/t/:slug', async (req, res) => {
r.get('/rundown/t/:slug', async (req, res) => {
const { slug } = req.params;
const { slug } = req.params;
- if (!isSlug(slug)) return notFound(res);
+ if (!isSlug(slug)) return notFound(req, res);
const topic = await live.topic(slug);
const topic = await live.topic(slug);
- if (!topic) return notFound(res);
- return send(res, views.topic({ topic }));
+ if (!topic) return notFound(req, res);
+ return send(req, res, views.topic({ topic }));
});
});
r.get('/rundown/e/:streamId', async (req, res) => {
r.get('/rundown/e/:streamId', async (req, res) => {
- if (!isStreamId(req.params.streamId)) return notFound(res);
+ if (!isStreamId(req.params.streamId)) return notFound(req, res);
const episode = await live.episode(streamIdOf(req.params.streamId));
const episode = await live.episode(streamIdOf(req.params.streamId));
- if (!episode) return notFound(res);
- return send(res, views.episode({ episode }));
+ if (!episode) return notFound(req, res);
+ return send(req, res, views.episode({ episode }));
});
});
r.get('/rundown/s/:username', async (req, res) => {
r.get('/rundown/s/:username', async (req, res) => {
const { username } = req.params;
const { username } = req.params;
- if (!isUsername(username)) return notFound(res);
+ if (!isUsername(username)) return notFound(req, res);
const speaker = await live.speaker(username);
const speaker = await live.speaker(username);
- if (!speaker) return notFound(res);
- return send(res, views.speaker({ speaker, username }));
+ if (!speaker) return notFound(req, res);
+ return send(req, res, views.speaker({ speaker, username }));
});
});
r.get('/after-show/:streamId', async (req, res) => {
r.get('/after-show/:streamId', async (req, res) => {
- if (!isStreamId(req.params.streamId)) return notFound(res);
+ if (!isStreamId(req.params.streamId)) return notFound(req, res);
const recap = await live.recap(streamIdOf(req.params.streamId));
const recap = await live.recap(streamIdOf(req.params.streamId));
- if (!recap) return notFound(res);
- return send(res, views.recap({ recap }));
+ if (!recap) return notFound(req, res);
+ return send(req, res, views.recap({ recap }));
});
});
r.get('/c/:username', async (req, res) => {
r.get('/c/:username', async (req, res) => {
const { username } = req.params;
const { username } = req.params;
- if (!isUsername(username)) return notFound(res);
+ if (!isUsername(username)) return notFound(req, res);
const recaps = await live.channelRecaps(username);
const recaps = await live.channelRecaps(username);
- if (!recaps) return notFound(res);
- return send(res, views.channel({ username, recaps }));
+ if (!recaps) return notFound(req, res);
+ return send(req, res, views.channel({ username, recaps }));
});
});
return r;
return r;
}
}
-module.exports = { createPageRoutes };
+module.exports = { createPageRoutes, createPublicPages };
@@ -77,7 +77,7 @@ const NAV = [
@@ -77,7 +77,7 @@ const NAV = [
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
];
-function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [] }) {
+function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false }) {
canonical = canonical || (path ? canon(path) : undefined);
canonical = canonical || (path ? canon(path) : undefined);
const navbar = {
const navbar = {
service: 'report',
service: 'report',
@@ -95,6 +95,9 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
@@ -95,6 +95,9 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
siteName: NAME,
siteName: NAME,
description: description || 'The network\'s numbers open to everyone, what streamers talked about in The Rundown, and every stream\'s after-show report.',
description: description || 'The network\'s numbers open to everyone, what streamers talked about in The Rundown, and every stream\'s after-show report.',
canonical,
canonical,
+ // A page with a Markdown twin (server/agent-pages.js) names it in the head; only twinned pages set agent.
+ agentLayer: agent,
+ llms: `${BASE}/llms.txt`,
robots,
robots,
jsonLd,
jsonLd,
home: '/',
home: '/',
@@ -222,7 +225,7 @@ function home({ stats, rundown, topics } = {}) {
@@ -222,7 +225,7 @@ function home({ stats, rundown, topics } = {}) {
['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],
['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],
] },
] },
});
});
- return layout({ title: null, body, active: '/', path: '/' });
+ return layout({ title: null, body, active: '/', path: '/', agent: true });
}
}
function stats({ stats: d, days = 30 } = {}) {
function stats({ stats: d, days = 30 } = {}) {
@@ -249,7 +252,7 @@ ${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site<
@@ -249,7 +252,7 @@ ${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site<
${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}
${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}
${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}
${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}
<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;
<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;
- return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}` });
+ return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });
}
}
function rundown({ rundown: R = {}, topics } = {}) {
function rundown({ rundown: R = {}, topics } = {}) {
@@ -341,7 +344,7 @@ function developers() {
@@ -341,7 +344,7 @@ function developers() {
const body = `<header class="rp-page-h"><p class="rp-kicker">For developers</p><h1>The Report API</h1><p class="rp-lede">Open JSON, no key, readable from any site (CORS). Counts only, never about a person.</p></header>
const body = `<header class="rp-page-h"><p class="rp-kicker">For developers</p><h1>The Report API</h1><p class="rp-lede">Open JSON, no key, readable from any site (CORS). Counts only, never about a person.</p></header>
<section aria-labelledby="rp-ep-h"><h2 id="rp-ep-h">Endpoints</h2><dl class="rp-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
<section aria-labelledby="rp-ep-h"><h2 id="rp-ep-h">Endpoints</h2><dl class="rp-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
<p class="rp-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;
<p class="rp-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;
- return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });
+ return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers', agent: true });
}
}
function benchState(...reads) {
function benchState(...reads) {
@@ -376,7 +379,7 @@ function aiIndex({ kinds, boards = [] }) {
@@ -376,7 +379,7 @@ function aiIndex({ kinds, boards = [] }) {
+ `<p class="rp-meta rp-ai-links"><a href="${href}">Elo</a><span aria-hidden="true">·</span><a href="${href}?tab=pareto">Pareto</a><span aria-hidden="true">·</span><a href="${href}?tab=cost">Cost per solved</a></p></li>`;
+ `<p class="rp-meta rp-ai-links"><a href="${href}">Elo</a><span aria-hidden="true">·</span><a href="${href}?tab=pareto">Pareto</a><span aria-hidden="true">·</span><a href="${href}?tab=cost">Cost per solved</a></p></li>`;
}).join('')}</ul></section>`).join('');
}).join('')}</ul></section>`).join('');
const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
const body = `<header class="rp-page-h"><p class="rp-kicker">Benchmarks</p><h1>AI boards</h1><p class="rp-lede">Every benchmark kind across OpenVibe rooms, with ratings, quality and cost.</p></header>${benchState(kinds, ...boards)}${sections ? `<div class="rp-ai-rooms">${sections}</div>` : '' }${sections ? '' : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
- return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai' });
+ return layout({ title: 'AI boards', description: 'AI benchmark leaders and boards across OpenVibe rooms.', body, active: '/ai', path: '/ai', agent: true });
}
}
function aiTable(headers, rows) {
function aiTable(headers, rows) {
@@ -423,7 +426,7 @@ function aiBoard({ kindId, kind, tab, board, cost, kinds }) {
@@ -423,7 +426,7 @@ function aiBoard({ kindId, kind, tab, board, cost, kinds }) {
if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();
if (tab === 'cost') content = rows.length ? aiTable(['Rank', 'Model', 'Solved', 'Entries', 'Total cost', 'Cost per solved'], rows.map((r, i) => `<tr><td>${i + 1}</td><th scope="row">${modelLink(r.subject)}</th><td>${exact(r.solved)}</td><td>${exact(r.entries)}</td><td>${usd(r.total_cost_usd)}</td><td>${usd(r.cost_per_solved)}</td></tr>`)) : emptyBoard();
if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';
if (!board) content = '<p class="rp-fine">Bench results are temporarily unavailable.</p>';
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a>${kind ? ` · ${esc(roomName(kind.room))}` : ''}</p><h1>${esc(name)}</h1><p class="rp-lede">${kind ? esc(judgeWord(kind.judge).replace(/^./, (c) => c.toUpperCase())) : 'Bench results'}.</p><nav class="rp-tabs" aria-label="Board view">${tabs}</nav></header>${benchState(kinds, board, cost)}<section aria-label="${esc(tab)} board">${content}</section>${datasetLink(kindId)}`;
- return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId) });
+ return layout({ title: `${name}: AI board`, description: `${name} AI benchmark ratings, quality and cost.`, body, active: '/ai', path: aiPath(kindId), agent: true });
}
}
function aiModel({ model, summary }) {
function aiModel({ model, summary }) {
@@ -437,7 +440,7 @@ function aiModel({ model, summary }) {
@@ -437,7 +440,7 @@ function aiModel({ model, summary }) {
return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;
return `<tr><th scope="row"><a href="${aiPath(kind)}">${esc(kind)}</a></th><td>${r ? rating(r) : '—'}${r ? flag(r) : ''}</td><td>${r ? exact(r.games) : '—'}</td><td>${r ? share(r.win_rate) : '—'}</td><td>${count}</td></tr>`;
});
});
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/ai">AI boards</a> · Model</p><h1>${esc(model)}</h1><p class="rp-lede">Performance across every kind this model has played.</p></header>${benchState(summary)}${data ? `<p class="rp-meta">Total cost: ${usd(data.total_cost_usd)} · ${kinds.length} kinds</p>${rows.length ? aiTable(['Kind', 'Rating (95% interval)', 'Games', 'Win rate', 'Recent entries'], rows) : emptyBoard()}` : '<p class="rp-fine">Bench results are temporarily unavailable.</p>'}`;
- return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model) });
+ return layout({ title: `${model}: AI model`, description: `${model} across OpenVibe benchmark kinds.`, body, active: '/ai', path: modelPath(model), agent: true });
}
}
function notFound() {
function notFound() {