openvibers/openvibe.shared Commits

4.34.0: agent layer — shared agent.json manifest and public Markdown twins (#167)

OP OpenVibers committed 15 days ago · c1b089f19c · 11 files, +358 −35

* 4.33.0: 15 more navbar glyphs drawn inline (gauge, pen, earth-americas, hashtag, inbox, folder, puzzle-piece, shield, display, microphone, infinity, list, flag-checkered, tv, compass): sites named them and they showed empty without an icon font

* Add shared agent manifest and public Markdown twins

* Prepare Shared 4.34.0 agent-layer release

* STATUS.json: 4.34.0 (the agent-layer release)

* Site template: pin openvibe-shared v4.34.0 (the template sat 33 minors behind, failing pin drift)

* Site template: pin openvibe-shared v4.33.0 (the template sat 33 minors behind, failing pin drift)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e22fba1..87069e2 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,13 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.34.0 — 2026-10-29
++
++- **Agent discovery:** the server-only `agent-layer` module serves `/.well-known/agent.json`
++ from a service descriptor and Markdown twins from the same public renderer as HTML.
++ Frame discovery links, Link headers, and optional Markdown links in `llms.txt` accompany
++ the three-line mount; see [the adoption guide](docs/agent-layer.md). Browser assets are unchanged.
++
## 4.33.0 — 2026-10-28
- **Navbar icons:** 15 more Font Awesome solid glyphs are drawn inline (`nav-icons.js`, loaded after first paint):
ddiff --git a/README.md b/README.md
iindex f75db38..1d4a1ed 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.33.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.34.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -58,6 +58,11 @@ res.type('html').send(shell.page({
See "The page shell" below for the rest of the options.
++### Agent discovery and Markdown twins
++
++`openvibe-shared/agent-layer` mounts the agent manifest and public Markdown twins from
++the same server renderer as HTML. See [the three-line mount and service descriptor guide](docs/agent-layer.md).
++
### Server side: pin a release tarball
```json
ddiff --git a/STATUS.json b/STATUS.json
iindex 915fd1b..c51b73f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.33.0",
++ "version": "4.34.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/agent-layer.js b/agent-layer.js
nnew file mode 100644
iindex 0000000..ec901be
---- /dev/null
++++ b/agent-layer.js
@@@ -0,0 +1,116 @@
++'use strict';
++// Server-only discovery and representations of the site's own public render output.
++const { parseHTML } = require('linkedom');
++const esc = s => String(s).replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]));
++const md = s => String(s || '').replace(/\\/g, '\\\\').replace(/([\[\]*_`])/g, '\\$1');
++
++function twinUrl(value) {
++ const url = new URL(value, 'https://agent.invalid');
++ url.searchParams.delete('format');
++ if (!url.pathname.endsWith('.md')) url.pathname = url.pathname === '/' ? '/index.md' : url.pathname.replace(/\/$/, '') + '.md';
++ return /^[a-z]+:/i.test(value) ? url.href : url.pathname + url.search + url.hash;
++}
++
++function agentManifest(descriptor = {}, overrides = {}) {
++ const d = { ...descriptor, ...overrides };
++ const home = d.home || d.canonical;
++ const abs = value => value && home ? new URL(value, home).href : value;
++ return {
++ network: 'OpenVibe', site: d.site || (home ? new URL(home).hostname : undefined),
++ id: d.id, name: d.name, purpose: d.purpose, home,
++ api: abs(d.api), openapi: abs(d.openapi),
++ mcp: d.mcp ? { transport: 'streamable-http', ...d.mcp, url: abs(d.mcp.url) } : undefined,
++ docs: abs(d.docs), llms: abs(d.llms || (home && '/llms.txt')),
++ llmsFull: abs(d.llmsFull || (home && '/llms-full.txt')), sitemap: abs(d.sitemap || (home && '/sitemap.xml')),
++ policy: { robots: 'https://openvibe.network/bot.json', ...d.policy },
++ };
++}
++
++function linkHeaderTags(canonical, llms = '/llms.txt') {
++ const twin = twinUrl(canonical);
++ // URL encoding keeps arbitrary caller input out of HTTP header syntax.
++ const headerUrl = s => String(s).replace(/[<>"\r\n]/g, c => encodeURIComponent(c));
++ return {
++ head: `<link rel="alternate" type="text/markdown" href="${esc(twin)}">\n<link rel="describedby" href="${esc(llms)}">`,
++ header: `<${headerUrl(twin)}>; rel="alternate"; type="text/markdown", <${headerUrl(llms)}>; rel="describedby"`,
++ };
++}
++
++function markdownTwin({ html, title, canonical, updated = null }) {
++ const { document } = parseHTML(html);
++ title = title || document.querySelector('title')?.textContent || '';
++ const walk = node => {
++ if (node.nodeType === 3) return md(node.textContent.replace(/\s+/g, ' '));
++ if (node.nodeType !== 1) return '';
++ const tag = node.localName;
++ if (['script', 'style', 'template', 'nav', 'footer'].includes(tag) || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return '';
++ const inside = () => Array.from(node.childNodes).map(walk).join('');
++ if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${inside().trim()}\n\n`;
++ if (tag === 'a') return node.hasAttribute('href') ? `[${inside()}](${destination(node.getAttribute('href'))})` : inside();
++ if (tag === 'img') return `![${md(node.getAttribute('alt') || '')}](${destination(node.getAttribute('src') || '')})`;
++ if (tag === 'br') return '\n';
++ if (tag === 'strong' || tag === 'b') return `**${inside()}**`;
++ if (tag === 'em' || tag === 'i') return `*${inside()}*`;
++ if (tag === 'pre') return `\n\n~~~~\n${node.textContent}\n~~~~\n\n`;
++ if (tag === 'code') return '`' + node.textContent.replace(/`/g, '\\`') + '`';
++ if (tag === 'ul' || tag === 'ol') {
++ return '\n\n' + Array.from(node.children).filter(n => n.localName === 'li').map((n, i) => {
++ const text = Array.from(n.childNodes).map(walk).join('').trim();
++ const prefix = tag === 'ol' ? `${Number(node.getAttribute('start') || 1) + i}. ` : '- ';
++ return prefix + text.replace(/\n/g, '\n' + ' '.repeat(prefix.length));
++ }).join('\n') + '\n\n';
++ }
++ if (tag === 'table') {
++ const rows = Array.from(node.querySelectorAll('tr')).map(row => Array.from(row.children).filter(n => /^(th|td)$/.test(n.localName)).map(n => walk(n).trim().replace(/\|/g, '\\|').replace(/\n+/g, '<br>')));
++ if (!rows.length) return '';
++ const width = Math.max(...rows.map(r => r.length));
++ const line = row => '| ' + Array.from({ length: width }, (_, i) => row[i] || '').join(' | ') + ' |';
++ return '\n\n' + [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n';
++ }
++ const content = inside();
++ return /^(p|div|section|article|main|blockquote|dl|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content;
++ };
++ const destination = value => String(value).replace(/[\s()<>]/g, c => encodeURIComponent(c).replace(/\(/g, '%28').replace(/\)/g, '%29'));
++ const content = walk(document.querySelector('main') || document.body).replace(/\n[ \t]+\n/g, '\n\n').replace(/\n{3,}/g, '\n\n').trim();
++ return `---\ntitle: ${JSON.stringify(title)}\ncanonical: ${JSON.stringify(canonical)}\nupdated: ${JSON.stringify(updated)}\n---\n\n# ${md(title)}\n\nHTML: [${md(title || 'Original page')}](${destination(canonical)})\n\n${content}\n`;
++}
++
++/** Mount before public HTML routes. render(path, { query }) returns { html, title?, updated? } or null.
++ * It must use the same public snapshot renderer as HTML and must never return personalised content.
++ */
++function mount(app, { descriptor = {}, render }) {
++ const manifest = agentManifest(descriptor);
++ app.use((req, res, next) => {
++ if (!['GET', 'HEAD'].includes(req.method)) return next();
++ const url = new URL(req.url, 'https://agent.invalid');
++ if (url.pathname === '/.well-known/agent.json') {
++ res.setHeader('Content-Type', 'application/json; charset=utf-8');
++ res.setHeader('Cache-Control', 'public, max-age=3600');
++ return res.end(req.method === 'HEAD' ? undefined : JSON.stringify(manifest));
++ }
++ const isTwin = url.pathname.endsWith('.md') || url.searchParams.get('format') === 'markdown' || (req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
++ let path = url.pathname;
++ if (path === '/index.md') path = '/';
++ else if (path.endsWith('.md')) path = path.slice(0, -3);
++ url.searchParams.delete('format');
++ if (!render) return next();
++ Promise.resolve().then(() => render(path, { query: url.searchParams })).then(page => {
++ if (!page) {
++ if (!isTwin) return next();
++ res.statusCode = 404;
++ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
++ return res.end(req.method === 'HEAD' ? undefined : '# Not found\n');
++ }
++ const canonical = new URL(path + url.search, manifest.home).href;
++ const links = linkHeaderTags(path + url.search, manifest.llms || '/llms.txt');
++ res.setHeader('Link', [res.getHeader('Link'), links.header].filter(Boolean).join(', '));
++ res.setHeader('Vary', [res.getHeader('Vary'), 'Accept'].filter(Boolean).join(', '));
++ res.setHeader('Cache-Control', 'public, max-age=300');
++ res.setHeader('Content-Type', isTwin ? 'text/markdown; charset=utf-8' : 'text/html; charset=utf-8');
++ const body = isTwin ? markdownTwin({ ...page, canonical }) : page.html.replace(/<link\b(?=[^>]*\brel=["'](?:alternate|describedby)["'])(?=[^>]*(?:text\/markdown|rel=["']describedby["']))[^>]*>/gi, '').replace(/<\/head>/i, links.head + '\n</head>');
++ res.end(req.method === 'HEAD' ? undefined : body);
++ }).catch(next);
++ });
++}
++
++module.exports = { mount, agentManifest, markdownTwin, linkHeaderTags, twinUrl };
ddiff --git a/docs/agent-layer.md b/docs/agent-layer.md
nnew file mode 100644
iindex 0000000..2fb31cd
---- /dev/null
++++ b/docs/agent-layer.md
@@@ -0,0 +1,140 @@
++# Shared agent layer
++
++Mount at the application root before public HTML routes and the 404 handler:
++
++```js
++const agent = require('openvibe-shared/agent-layer');
++const descriptor = require('./agent-descriptor');
++agent.mount(app, { descriptor, render: renderPublicPage });
++```
++
++`renderPublicPage(path, { query })` returns (or resolves to) `{ html, title?, updated? }`,
++or `null` for an unknown/non-public page. `query` is a URLSearchParams with `format` removed.
++Use the **existing server renderer** (e.g. `shell.page` and Kit builders) for `html`.
++The mount serves both HTML and Markdown from that same renderer. Extract existing route
++rendering into this function; do not fetch your own HTTP server, duplicate page content, or
++return authenticated/personalised pages. Unhandled HTML requests continue to existing routes;
++unknown twins return 404. Render failures pass to Express's error handler. GET and HEAD only.
++Paths ending in `.md` are reserved for twins. A trailing-slash page should accept its
++slashless path too, since `/guide/` advertises `/guide.md`.
++
++## Descriptor and manifest
++
++`agentManifest(descriptor, overrides?)` returns a JSON-serialisable object. Missing optional
++fields are omitted, without placeholders or validation failures. Relative endpoint URLs resolve
++against `home`. Supply a valid absolute `home` when mounting rendered pages.
++
++| Descriptor | Manifest / rule |
++| --- | --- |
++| `id`, `name`, `purpose` | Service id, display name, one-line purpose |
++| `home` (or `canonical`) | Canonical service URL, emitted as `home` |
++| `site` | Hostname; defaults to the hostname of `home` |
++| `api`, `openapi`, `docs` | API base, actual OpenAPI document path, actual documentation URL; omit unavailable endpoints |
++| `mcp` | `{ url, transport: 'streamable-http', auth }`; omit if unavailable |
++| `llms`, `llmsFull`, `sitemap` | Defaults: `/llms.txt`, `/llms-full.txt`, `/sitemap.xml` |
++| `policy.robots` | Defaults to `https://openvibe.network/bot.json` |
++| `policy.auth` | Site-provided schemes: `['none']`, `['Network OIDC']`, `['bearer']`, or a combination |
++| `policy.rateLimit` | Actual rate-limit note or `{ requestsPerMinute, burst }` |
++| `policy.crawlDelayMs`, `stableIds`, `pagination`, `errors` | Optional verified policy details; no invented service guarantees |
++
++`network` is `OpenVibe`. `/.well-known/agent.json` returns JSON with a one-hour public cache.
++The shape follows §3 of the network SEO/agents proposal: `home`, `llms`, `llmsFull`,
++`sitemap`, `openapi`, `mcp`, and nested `policy`; identity, purpose, API base and docs extend it.
++
++In each service's robots.txt builder, append this **comment** (not a made-up robots directive):
++
++```text
++# Agent manifest: https://YOUR-HOST/.well-known/agent.json
++```
++
++Keep the site's existing crawler, disallow and sitemap directives.
++
++## Markdown twins and discovery
++
++`/` has `/index.md`; `/guide` has `/guide.md`. `?format=markdown` and an Accept preference
++for `text/markdown` also return `text/markdown; charset=utf-8`. Query parameters survive in
++canonical and twin links. Public HTML and Markdown receive a five-minute public cache,
++a `Vary: Accept` header, and a Link header naming the Markdown alternate and llms.txt.
++HTML receives both discovery `<link>` tags inside the Frame's head. The mount works with
++existing Frame layouts; standalone `shell.page({ agentLayer: true, canonical, ... })` also
++adds the tags (only enable where twins are mounted).
++
++`markdownTwin({ html, title?, canonical, updated? })` uses the existing LinkeDOM parser,
++now a runtime dependency. It reads `<main>` (or the body), ignores scripts, styles, templates,
++navigation, footers and explicitly hidden elements, and requires no client JavaScript.
++It emits YAML `title`, `canonical`, `updated`, a title heading, and an HTML backlink.
++Unknown update times are `null`, never the request time. Real headings remain in order
++(including a page's own h1), links and image alt text come from the rendered page, missing
++alt remains empty, lists retain ordering/nesting, and tables use pipe rows. The first table
++row is the Markdown header. CSS-generated content and arbitrary browser layout are outside
++this server-rendered conversion's scope.
++
++`linkHeaderTags(canonical, llms?)` returns `{ head, header }`; `twinUrl(url)` constructs
++an alternate URL. `seo` also exports `agentManifest`, `markdownTwin`, `linkHeaderTags`.
++For AGT-5 use `seo.llmsTxt({ ..., markdown: true, optional: [...] })` **after** mounting
++all linked twins. `optional` renders `## Optional`. A link with `html: true` stays HTML
++and is explicitly labelled `(HTML)`; use this for external or unsupported pages. Existing
++callers remain unchanged until their service adopts twins.
++
++## Per-service descriptor rollout
++
++Every service below needs its own descriptor. **Each** must supply `id`, `name`, `purpose`,
++`home`, `policy.auth`, and `policy.rateLimit`; supply `docs`, `api`, `openapi`, and `mcp`
++only when those endpoints exist. Override discovery paths and other policy fields where
++needed. The research inventory is a rollout list, not proof that an API/OpenAPI/MCP endpoint
++exists: verify each in its service before advertising it. Bench's reported MCP endpoint is
++`/mcp`; Tools and Services have reported OpenAPI documents whose actual paths must be used.
++
++- OpenVibe.Network
++- OpenVibe.Live
++- OpenVibe.Video
++- OpenVibe.Chat
++- OpenVibe.Games
++- OpenVibe.Tools
++- OpenVibe.Services
++- OpenVibe.Help
++- OpenVibe.Deals
++- OpenVibe.Host
++- OpenVibe.Wiki
++- OpenVibe.News
++- OpenVibe.Blog
++- OpenVibe.Media
++- OpenVibe.Community
++- OpenVibe.Reviews
++- OpenVibe.Coupons
++- OpenVibe.Trade
++- OpenVibe.Tips
++- OpenVibe.VIP
++- OpenVibe.Space
++- OpenVibe.Work
++- OpenVibe.Quest
++- OpenVibe.Rent
++- OpenVibe.Food
++- OpenVibe.Events
++- OpenVibe.Actor
++- OpenVibe.Bot
++- OpenVibe.Codes
++- OpenVibe.Run
++- OpenVibe.Zone
++- OpenVibe.Play
++- OpenVibe.Search
++- OpenVibe.Inventory
++- OpenVibe.AI
++- OpenVibe.Cam
++- OpenVibe.Bench
++- OpenVibe.Design
++- OpenVibe.Today
++- OpenVibe.Surf
++- OpenVibe.Meme
++- OpenVibe.Vision
++- OpenVibe.Exchange
++- OpenVibe.Fun
++- OpenVibe.Domains
++- OpenVibe.Pics
++- OpenVibe.Download
++- OpenVibe.Homes
++- OpenVibe.Publishing
++- OpenRestream
++
++No browser files or browser allowlist entries are added. `npm run size` measures the
++existing browser assets; this module and parser run only on the server.
ddiff --git a/package-lock.json b/package-lock.json
iindex 9f468c6..ae6bc79 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,15 +1,16 @@
{
"name": "openvibe-shared",
-- "version": "4.17.0",
++ "version": "4.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.17.0",
++ "version": "4.34.0",
"license": "MIT",
"dependencies": {
-- "jsonwebtoken": "^9.0.0"
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
},
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
@@@ -17,7 +18,6 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
-- "linkedom": "^0.18.13",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"
}
@@@ -114,7 +114,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
-- "dev": true,
"license": "ISC",
"engines": {
"node": ">=20.19.0"
@@@ -215,7 +214,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0",
@@@ -236,7 +234,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
-- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
@@@ -250,7 +247,6 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
-- "dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@@ -295,7 +291,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
-- "dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
@@@ -314,7 +309,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
-- "dev": true,
"funding": [
{
"type": "github",
@@@ -330,7 +324,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
@@@ -347,7 +340,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
@@@ -407,7 +399,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
-- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
@@@ -668,14 +659,12 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
-- "dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
-- "dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@@ -695,7 +684,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
-- "dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@@ -710,7 +698,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
-- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@@ -723,7 +710,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
-- "dev": true,
"funding": [
{
"type": "github",
@@@ -736,7 +722,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@@ -752,7 +737,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@@@ -767,7 +751,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
-- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@@ -881,7 +864,6 @@
"version": "0.18.13",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
"integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
-- "dev": true,
"license": "ISC",
"dependencies": {
"css-select": "^7.0.0",
@@@ -1040,7 +1022,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
-- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0"
@@@ -1414,7 +1395,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
-- "dev": true,
"license": "ISC"
},
"node_modules/unpipe": {
ddiff --git a/package.json b/package.json
iindex 09ca222..8dff835 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.33.0",
++ "version": "4.34.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -82,7 +82,8 @@
"./route-transition": "./route-transition.js",
"./boost": "./boost.js",
"./rooms": "./rooms.js",
-- "./motion": "./motion.js"
++ "./motion": "./motion.js",
++ "./agent-layer": "./agent-layer.js"
},
"files": [
"*.js",
@@@ -93,7 +94,8 @@
"icons/",
"social/",
"templates/",
-- "CHANGELOG.md"
++ "CHANGELOG.md",
++ "docs/agent-layer.md"
],
"scripts": {
"test": "node test/run.js",
@@@ -110,12 +112,12 @@
"homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
"license": "MIT",
"dependencies": {
-- "jsonwebtoken": "^9.0.0"
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
-- "linkedom": "^0.18.13",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"
}
ddiff --git a/seo.js b/seo.js
iindex f111f5d..55cf4a8 100644
---- a/seo.js
++++ b/seo.js
@@@ -238,13 +238,13 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true
}
/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
--function llmsTxt({ name, summary, details, sections = [] } = {}) {
++function llmsTxt({ name, summary, details, sections = [], markdown = false, optional = [] } = {}) {
const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
if (details) out.push(String(details).trim(), '');
-- for (const sec of sections) {
++ for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
if (!sec || !sec.links || !sec.links.length) continue;
out.push(`## ${sec.title}`, '');
-- for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
++ for (const l of sec.links) out.push(`- [${l.title}](${markdown && !l.html ? require('./agent-layer').twinUrl(l.url) : l.url})${l.html ? ' (HTML)' : ''}${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
out.push('');
}
return out.join('\n');
@@@ -385,3 +385,8 @@ function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {
}
module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, llmsFull, pageSummary, feedXml, feedLinkTags, clip, esc, absolute, AI_AND_SEARCH_BOTS };
++
++// Lazy server-only agent helpers; ordinary SEO rendering need not load the HTML parser.
++for (const name of ['agentManifest', 'markdownTwin', 'linkHeaderTags']) {
++ module.exports[name] = (...args) => require('./agent-layer')[name](...args);
++}
ddiff --git a/shell.js b/shell.js
iindex e0f2532..5ff9595 100644
---- a/shell.js
++++ b/shell.js
@@@ -139,6 +139,7 @@ function page(o = {}) {
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
head,
++ o.agentLayer && o.canonical ? require('./agent-layer').linkHeaderTags(o.canonical, o.llms).head : '',
summary ? summary.head : '',
o.head || '',
manifestLinks(o),
ddiff --git a/templates/site/package.json b/templates/site/package.json
iindex 9f9584a..e71a624 100644
---- a/templates/site/package.json
++++ b/templates/site/package.json
@@@ -9,6 +9,6 @@
},
"dependencies": {
"express": "^4.22.3",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"
}
}
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
nnew file mode 100644
iindex 0000000..2ced040
---- /dev/null
++++ b/test/agent-layer.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++const assert = require('node:assert/strict');
++const express = require('express');
++const agent = require('../agent-layer');
++const seo = require('../seo');
++const shell = require('../shell');
++const kit = require('../kit');
++const descriptor = { id: 'example', name: 'Example', purpose: 'Public examples', home: 'https://example.com/', docs: '/docs', api: '/api', openapi: '/openapi.json', mcp: { url: '/mcp', auth: 'bearer' }, policy: { auth: ['none', 'Network OIDC', 'bearer'], rateLimit: '60 requests/minute' } };
++const manifest = JSON.parse(JSON.stringify(agent.agentManifest(descriptor)));
++assert.equal(manifest.openapi, 'https://example.com/openapi.json');
++assert.equal(manifest.mcp.transport, 'streamable-http');
++assert.equal(manifest.policy.robots, 'https://openvibe.network/bot.json');
++assert.deepEqual(manifest.policy.auth, descriptor.policy.auth);
++assert.equal(agent.agentManifest(descriptor, { name: 'Override' }).name, 'Override');
++assert.deepEqual(JSON.parse(JSON.stringify(agent.agentManifest())), { network: 'OpenVibe', policy: { robots: 'https://openvibe.network/bot.json' } });
++const html = shell.page({ title: 'Guide & examples', canonical: 'https://example.com/guide', body: '<main><h1>Guide</h1>' + kit.section({ title: 'Steps', body: '<p>Read <a href="/docs">the docs</a>.</p><img src="/photo.png" alt="A real photo"><img src="/blank.png"><ol start="3"><li>First<ul><li>Nested</li></ul></li><li>Second</li></ol><table><tr><th>Name</th><th>Value</th></tr><tr><td>A | B</td><td>1</td></tr></table><h3>Details</h3><script>secretCode()</script><div hidden>hiddenText</div>' }) + '</main>' });
++const app = express();
++agent.mount(app, { descriptor, render: path => path === '/guide' || path === '/' ? { html, updated: '2026-10-29' } : path === '/error' ? Promise.reject(new Error('render failed')) : null });
++app.use((err, req, res, next) => res.status(500).send(err.message));
++(async () => {
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise(resolve => server.once('listening', resolve));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ try {
++ const m = await fetch(base + '/.well-known/agent.json');
++ assert.match(m.headers.get('content-type'), /^application\/json/);
++ assert.equal(m.headers.get('cache-control'), 'public, max-age=3600');
++ assert.deepEqual(await m.json(), manifest);
++ for (const path of ['/guide.md', '/guide?format=markdown', '/index.md']) {
++ const res = await fetch(base + path);
++ assert.equal(res.status, 200);
++ assert.equal(res.headers.get('content-type'), 'text/markdown; charset=utf-8');
++ assert.equal(res.headers.get('cache-control'), 'public, max-age=300');
++ const text = await res.text();
++ assert.match(text, /^---\ntitle: "Guide & examples"\ncanonical:/);
++ assert.match(text, /updated: "2026-10-29"/);
++ assert.match(text, /# Guide & examples/);
++ assert.ok(text.indexOf('# Guide\n') < text.indexOf('## Steps'));
++ assert.ok(text.indexOf('## Steps') < text.indexOf('### Details'));
++ assert.match(text, /\[the docs\]\(\/docs\)/);
++ assert.match(text, /!\[A real photo\]\(\/photo.png\)/);
++ assert.match(text, /!\[\]\(\/blank.png\)/);
++ assert.match(text, /3\. First/);
++ assert.match(text, / - Nested/);
++ assert.match(text, /\| Name \| Value \|\n\| --- \| --- \|\n\| A \\\| B \| 1 \|/);
++ assert.ok(!text.includes('secretCode') && !text.includes('hiddenText'));
++ }
++ const res = await fetch(base + '/guide');
++ assert.match(res.headers.get('link'), /<\/guide.md>; rel="alternate"; type="text\/markdown"/);
++ assert.equal(res.headers.get('vary'), 'Accept');
++ const doc = await res.text();
++ assert.match(doc, /<link rel="alternate" type="text\/markdown" href="\/guide.md">/);
++ assert.match(doc, /<link rel="describedby" href="https:\/\/example.com\/llms.txt">/);
++ assert.match(shell.page({ canonical: descriptor.home, agentLayer: true }), /rel="describedby"/);
++ assert.equal((await fetch(base + '/missing.md')).status, 404);
++ assert.equal((await fetch(base + '/missing?format=markdown')).status, 404);
++ assert.equal((await fetch(base + '/error.md')).status, 500);
++ const head = await fetch(base + '/guide.md', { method: 'HEAD' });
++ assert.equal(await head.text(), '');
++ assert.equal(head.headers.get('content-type'), 'text/markdown; charset=utf-8');
++ assert.match((await fetch(base + '/guide', { headers: { Accept: 'text/markdown' } })).headers.get('content-type'), /text\/markdown/);
++ assert.equal((await fetch(base + '/guide.md', { method: 'POST' })).status, 404);
++ assert.match((await fetch(base + '/guide', { headers: { Accept: 'text/html, text/markdown;q=0' } })).headers.get('content-type'), /text\/html/);
++ assert.match(seo.llmsTxt({ name: 'Example', markdown: true, sections: [{ title: 'Pages', links: [{ title: 'Home', url: descriptor.home }] }], optional: [{ title: 'External', url: 'https://other.example/', html: true }] }), /index.md\)[\s\S]*## Optional[\s\S]*\(HTML\)/);
++ console.log('agent-layer: manifest, real Frame/Kit twins, discovery, methods, errors and llms checks passed');
++ } finally { await new Promise(resolve => server.close(resolve)); }
++})().catch(err => { console.error(err); process.exitCode = 1; });