openvibers/openvibe.report Commits

Agent layer: /.well-known/agent.json and Markdown twins of the numbers, AI boards and a model page (#7)

OP OpenVibers committed 15 days ago · a25a675cfc · 8 files, +446 −70

openvibe-shared v4.34.0 (was v4.33.0). server/agent-descriptor.js is what openvibe.report tells an agent
about itself: the JSON API, /developers as its documentation, the crawl files it publishes, no auth and
its real 120-a-minute API limit. No OpenAPI document or MCP endpoint exists for Report, so neither is
named. The public pages' data moved into createPublicPages so the routes and server/agent-pages.js render
the very same page; the mount sits after the page routes, which keep their own cache policy (two minutes,
no-store for the AI boards), and a .md path skips the page routes so it reaches its twin. Only the twinned
pages set agent, so only they carry the two discovery links and the Link header. robots.txt gains the
manifest comment.

Co-authored-by: CommandCodeBot <[email protected]>

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 19560eb..007e2ba 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 58cd7b9..502af1f 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..abf1808
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -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',
++ },
++};
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
nnew file mode 100644
iindex 0000000..4269180
---- /dev/null
++++ b/server/agent-pages.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex a5bf188..d194507 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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());
});
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 3e28dc2..7416f64 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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/'] }),
);
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 20c4637..47b8b9c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex dfe51bd..175e1aa 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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 = 'i
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.',
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() {