openvibers/openvibe.actor Commits

Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#172)

OP OpenVibers committed 15 days ago · 3f51e59093 · 8 files, +388 −28

Mounts openvibe-shared/agent-layer (Shared 4.34.0) and bumps the pin. The descriptor
names what openvibe.actor really serves (api /api/v1, /docs, llms.txt and llms-full.txt,
none + Network OIDC + bearer, the limits the server enforces); Actor publishes no OpenAPI
document, so none is named. The public pages keep their own builders and
server/agent-layer-pages.js renders their twins through those same builders
(createPageRoutes().publicViews), so a twin cannot drift from its page: the front page
(anonymous: the task box is a sign-in prompt), the agents page, the API reference and
the update log. A task page is behind a person's token and deliberately has no twin, so
/tasks/:id.md is a 404. Only manifest and twin requests reach the mount; each public page
advertises its twin with both discovery links and the Link header. robots.txt gains the
manifest comment.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 26b1e1d..26e3c4f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.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.0"
},
"devDependencies": {
@@@ -27,6 +27,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.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@@ -116,6 +122,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",
@@@ -218,6 +237,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"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",
@@@ -255,6 +307,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",
@@@ -305,6 +424,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",
@@@ -563,6 +694,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",
@@@ -708,6 +943,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.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@@ -846,6 +1105,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",
@@@ -915,12 +1190,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"
@@@ -1363,6 +1639,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 995a2e1..344b30c 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.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.0"
},
"engines": {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
nnew file mode 100644
iindex 0000000..48fc531
---- /dev/null
++++ b/server/agent-descriptor.js
@@@ -0,0 +1,17 @@
++'use strict';
++// What openvibe.actor tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
++// Every field is something this site really serves; /docs is the API reference and the JSON API is /api/v1.
++// A task page is behind a person's token, so it is never part of this surface.
++module.exports = {
++ id: 'actor',
++ name: 'OpenVibe.Actor',
++ purpose: 'A router for agent work: a task in plain words goes to the best agent system for it, a second model family checks the answer, and the task records its cost and why that agent.',
++ home: 'https://openvibe.actor',
++ site: 'openvibe.actor',
++ api: '/api/v1',
++ docs: '/docs',
++ policy: {
++ auth: ['none', 'Network OIDC', 'bearer'],
++ rateLimit: '240 API requests a minute per address; 300 page requests a minute; 60 sign-in attempts per 15 minutes',
++ },
++};
ddiff --git a/server/agent-layer-pages.js b/server/agent-layer-pages.js
nnew file mode 100644
iindex 0000000..e5953c8
---- /dev/null
++++ b/server/agent-layer-pages.js
@@@ -0,0 +1,23 @@
++'use strict';
++// The Markdown twins of openvibe.actor's public pages (openvibe-shared/agent-layer), rendered by the same
++// page builders the routes use (createPageRoutes().publicViews + render/layout.js's renderPage), so a twin
++// cannot drift from its page. Anonymous only. Named agent-layer-pages because server/http/agent-pages.js
++// already means a person's own agents, and a task page is behind a token: it is not here.
++const { renderPage } = require('./render/layout');
++
++function createAgentLayerPages(ctx, publicViews) {
++ const views = publicViews;
++ const twin = (page, path) => (page ? { html: renderPage({ ...page, viewer: { kind: 'anonymous' }, config: ctx.config, path }) } : null);
++
++ async function renderPublicPage(path) {
++ if (path === '/') return twin(await views.home(), path);
++ if (path === '/agents') return twin(views.agents(), path);
++ if (path === '/docs') return twin(views.docs(), path);
++ if (path === '/updates') return twin(views.updates(), path);
++ return null;
++ }
++
++ return { renderPublicPage };
++}
++
++module.exports = { createAgentLayerPages };
ddiff --git a/server/app.js b/server/app.js
iindex bd603cc..3f121c9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -197,7 +197,22 @@ async function createApp(opts = {}) {
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(createAgentPages(ctx));
app.use(createConsolePages(ctx, opts.bot || {}));
-- app.use(createPageRoutes(ctx));
++ const pageRoutes = createPageRoutes(ctx);
++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
++ // pages. The pages themselves keep their own routes — a signed-in visitor's account bar and task box are
++ // theirs — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.
++ // A task page is behind a person's token: renderPublicPage deliberately returns null for it.
++ const agentLayer = express();
++ require('openvibe-shared/agent-layer').mount(agentLayer, {
++ descriptor: require('./agent-descriptor'),
++ render: require('./agent-layer-pages').createAgentLayerPages(ctx, pageRoutes.publicViews).renderPublicPage,
++ });
++ app.use((req, res, next) => {
++ const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
++ if (req.path !== '/.well-known/agent.json' && !twin) return next();
++ return agentLayer(req, res, next);
++ });
++ app.use(pageRoutes);
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">giving Actor a task</a>, <a href="/agents">the agents</a> or <a href="/docs">the API</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 00522a8..026f0b1 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -56,6 +56,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.actor: the public pages are for search and AI crawlers; tasks, sign-in and the API are not.\n'
++ + '# Agent manifest: https://openvibe.actor/.well-known/agent.json; the public pages have a .md twin.\n'
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 054f9ec..115f78d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -91,8 +91,8 @@ function createPageRoutes(ctx) {
const tier = config.allowance;
// ── The task box ─────────────────────────────────────────
-- function taskForm(req, { text = '', mode = 'balanced', problem = null } = {}) {
-- if (!signedIn(req)) {
++ function taskForm(viewer, { text = '', mode = 'balanced', problem = null } = {}) {
++ if (!(viewer.kind === 'user' && viewer.subject)) {
// A template picked while signed out waits in the box and comes back after sign-in.
const next = text ? `/?${new URLSearchParams({ task: text, mode })}` : '/#task';
return html`<div class="task-box task-box-guest">
@@@ -120,18 +120,19 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
return (Array.isArray(list) ? list : []).filter((t) => t && t.id && t.name);
}
-- async function home(req, res, { status = 200, form = {} } = {}) {
++ async function homePage({ viewer = { kind: 'anonymous' }, query = {}, form = {} } = {}) {
const agents = catalog.list(config);
const tools = await callableTools();
++ const signed = viewer.kind === 'user' && viewer.subject;
// A template link (/?task=…&mode=…#task) fills the task box; the page itself stays the canonical /.
-- if (!form.text && typeof req.query.task === 'string') form = { text: req.query.task.slice(0, 2000), mode: MODES.includes(req.query.mode) ? req.query.mode : 'balanced' };
++ if (!form.text && typeof query.task === 'string') form = { text: query.task.slice(0, 2000), mode: MODES.includes(query.mode) ? query.mode : 'balanced' };
const working = agents.filter((a) => a.available).length;
const faq = faqItems(agents);
const hero = showcase.hero({
eyebrow: 'OpenVibe.Actor · your agent for everything · alpha',
title: 'Ask once.', accent: 'The right agent does it.',
lede: 'Give Actor a task in plain words. It picks the agent that fits: OpenVibe\'s own, acting through OpenVibe Search and Tools, or another platform\'s, like OpenAI\'s web agent. It runs the task, has a second model check the answer, and shows you what it cost and why that agent. Like OpenRouter, for agents.',
-- actions: signedIn(req) ? [{ label: 'Give it a task', href: '#task', primary: true }, { label: 'Your tasks', href: '/tasks' }] : [{ label: 'Sign in to start', href: '/auth/login?next=%2F', primary: true }, { label: 'See the agents', href: '/agents' }],
++ actions: signed ? [{ label: 'Give it a task', href: '#task', primary: true }, { label: 'Your tasks', href: '/tasks' }] : [{ label: 'Sign in to start', href: '/auth/login?next=%2F', primary: true }, { label: 'See the agents', href: '/agents' }],
note: `Open source (AGPL-3.0). Free tier: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each. The list below says exactly what works today.`,
aside: { html: String(routerCard(agents)) },
});
@@@ -141,8 +142,8 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
[MODES.length, 'ways to choose', '#route'],
[tier.tasksPerDay, 'free tasks a day', '#faq'],
].filter(([n]) => n > 0);
-- page(req, res, {
-- index: true, cache: signedIn(req) && !req.query.task ? null : req.query.task ? cache.htmlHeaders({ private: true }) : PUBLIC_CACHE,
++ return {
++ agent: true, index: true, cache: signed && !query.task ? null : query.task ? cache.htmlHeaders({ private: true }) : PUBLIC_CACHE,
jsonLd: [...homeJsonLd(config),
seo.jsonLd.itemList('Agents OpenVibe.Actor routes tasks to', agents.map((a) => ({ name: a.name, url: `${config.baseUrl}/agents#${a.id}`, description: (catalog.byId(a.id) || {}).blurb }))),
seo.jsonLd.faq(faq.map(([q, a]) => ({ q, a })))],
@@@ -151,7 +152,7 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
body: html`${raw(hero)}
<section class="sc-sec hm-stats-sec" aria-label="OpenVibe.Actor in numbers"><ul class="hm-stats">${stats.map(([n, label, href]) => html`<li><a href="${href}"><b>${n}</b><span>${label}</span></a></li>`)}</ul></section>
<section class="sc-sec" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
--${taskForm(req, form)}</section>
++${taskForm(viewer, form)}</section>
<section class="sc-sec" aria-labelledby="h-templates"><h2 id="h-templates">Start from one of these</h2>
<p class="sc-lede">Tasks that work today. Pick one and it lands in the task box, ready to run or change.</p>
<ul class="hm-templates">${TEMPLATES.map((g) => html`<li class="hm-tgroup"><p class="hm-tgroup-head"><b>${g.group}</b></p>
@@@ -231,7 +232,12 @@ ${raw(showcase.code({
<div class="hm-faq">${faq.map(([q, a]) => html`<div class="hm-qa"><h3>${q}</h3><p>${a}</p></div>`)}</div></section>
${raw(showcase.network({ service: 'actor' }))}
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
-- }, status);
++ };
++ }
++
++ /** Render the home page through the route's own response shape (the POST failure path reuses it). */
++ async function home(req, res, { status = 200, form = {} } = {}) {
++ return page(req, res, await homePage({ viewer: req.viewer, query: req.query, form }), status);
}
/** Questions people ask first, answered from the running config and the catalog. */
@@@ -293,7 +299,7 @@ ${a.available ? '' : html`<p class="muted small">${a.reason}</p>`}
title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], styles: [showcase.STYLESHEET],
body: html`<h1>Your tasks</h1>
<p class="muted">Today: ${today.tasks} of ${tier.tasksPerDay} free tasks, ${usd(today.usd)} of ${usd(tier.perDayUsd)}. Tasks are kept ${store.RETENTION_DAYS} days.</p>
--${taskForm(req)}
++${taskForm(req.viewer)}
${table(['Task', 'State', 'Agent', 'Cost', 'Started'], pageRows.rows.map((t) => {
const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
return [html`<a href="/tasks/${t.id}">${t.task.length > 90 ? `${t.task.slice(0, 89)}…` : t.task}</a>`, badge(t.state, STATE_KIND[t.state]), result ? (catalog.byId(result.agent) || {}).name || result.agent : '—', usd(t.cost_usd == null ? null : Number(t.cost_usd)), time(t.created_at)];
@@@ -489,10 +495,10 @@ ${table(['Agent', 'Could take it', 'Estimated cost', 'Why not'], (p.candidates |
});
// ── The agents ───────────────────────────────────────────
-- r.get('/agents', (req, res) => {
++ function agentsPage() {
const agents = catalog.list(config);
-- page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ return {
++ agent: true, index: true, cache: PUBLIC_CACHE,
title: 'The agents',
description: 'Every agent system OpenVibe.Actor routes a task to — OpenVibe\'s own runtime, OpenAI\'s web agent, an open model on OpenVibe hardware, OpenVibe.Codes — with what each can do, its trust class and its price per million tokens.',
crumbs: [{ label: 'Agents' }],
@@@ -520,11 +526,12 @@ ${table(['Mode', 'What it picks'], MODES.map((m) => [html`<strong>${MODE_LABEL[m
<p>Before you see an answer, a model of another family reads it: DeepSeek's answers are checked by OpenAI's smallest model and OpenAI's by DeepSeek. In private mode only OpenVibe's own model may read the task, so it checks its own answer and the task says so. A failed check sends the task to the next agent that can do it.</p>
<h2 id="trust">Trust</h2>
<p><strong>first-party</strong> runs on OpenVibe's own hardware; <strong>external</strong> means the task's text goes to that provider (the OpenVibe runtime is OpenVibe's code, but its model runs at DeepSeek). Private mode takes only first-party agents.</p>`,
-- });
-- });
++ };
++ }
++ r.get('/agents', (req, res) => page(req, res, agentsPage()));
// ── The API reference ────────────────────────────────────
-- r.get('/docs', (req, res) => {
++ function docsPage() {
const b = config.baseUrl;
const create = `curl -s ${b}/api/v1/tasks \\
-H "authorization: Bearer $TOKEN" \\
@@@ -537,8 +544,8 @@ ${table(['Mode', 'What it picks'], MODES.map((m) => [html`<strong>${MODE_LABEL[m
id: 3
event: output
data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","step":"tool_call","tool":"run_tool","input":"ssl {\\"target\\":\\"example.com\\"}"}`;
-- page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ return {
++ agent: true, index: true, cache: PUBLIC_CACHE,
title: 'API',
description: 'The OpenVibe.Actor API: create a task, watch it live over server-sent events, read the result with its cost and explanation, cancel it; list the agents and dry-run the router.',
crumbs: [{ label: 'API' }],
@@@ -580,18 +587,25 @@ ${table(['Code', 'Meaning'], [
[html`<code>actor.check.unavailable</code>`, 'The answer could not be checked, so it was not delivered.'],
[html`<code>actor.task.timeout</code> / <code>actor.task.interrupted</code>`, 'It ran too long, or Actor restarted while it ran.'],
])}`,
-- });
-- });
++ };
++ }
++ r.get('/docs', (req, res) => page(req, res, docsPage()));
// ── Limits and the update log ────────────────────────────
r.get('/limits.json', (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
res.json({ service: 'actor', free_tier: { tasks_per_day: tier.tasksPerDay, per_task_usd: tier.perTaskUsd, per_day_usd: tier.perDayUsd }, task_timeout_seconds: Math.round(config.taskTimeoutMs / 1000), retention_days: store.RETENTION_DAYS });
});
-- r.get('/updates', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Actor', body: raw(frame.updatesBody({ service: 'actor', siteName: 'OpenVibe.Actor' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) }));
++ const updatesPage = () => ({ agent: true, index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Actor', body: raw(frame.updatesBody({ service: 'actor', siteName: 'OpenVibe.Actor' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) });
++ r.get('/updates', (req, res) => page(req, res, updatesPage()));
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
r.use(createDiscoveryRoutes(ctx));
++
++ // The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
++ // A task page is behind a person's token and is deliberately absent.
++ r.publicViews = { home: homePage, agents: agentsPage, docs: docsPage, updates: updatesPage };
++
return r;
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex af118eb..02c80d8 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -88,6 +88,10 @@ function renderPage(o) {
description: o.description || 'Give a task in plain words. Actor picks the best agent for it, ours or another platform\'s, checks the answer and shows the cost and why.',
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ // A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
++ // is server/agent-pages.js.
++ agentLayer: o.agent === true,
++ llms: `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`,
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
navLinks: NAV,
@@@ -126,6 +130,10 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
++ if (o.agent) {
++ const site = String(o.config.baseUrl).replace(/\/+$/, '');
++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
++ }
res.status(status).type('html').send(renderPage(o));
}