openvibers/openvibe.shared Commits
4.34.0: agent layer — shared agent.json manifest and public Markdown twins (#167)
Changes
@@@ -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):@@@ -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 deployedconsumer 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` namesthe 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@@@ -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)",nnew file mode 100644@@@ -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 => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[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 ` || '')})`;++ 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 };nnew file mode 100644@@@ -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.@@@ -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": {@@@ -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"}@@@ -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);++}@@@ -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),@@@ -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"}}nnew file mode 100644@@@ -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; });
@@ -6,6 +6,13 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,13 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 4.33.0 — 2026-10-28
- **Navbar icons:** 15 more Font Awesome solid glyphs are drawn inline (`nav-icons.js`, loaded after first paint):
- **Navbar icons:** 15 more Font Awesome solid glyphs are drawn inline (`nav-icons.js`, loaded after first paint):
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -58,6 +58,11 @@ res.type('html').send(shell.page({
@@ -58,6 +58,11 @@ res.type('html').send(shell.page({
See "The page shell" below for the rest of the options.
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
### Server side: pin a release tarball
```json
```json
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.33.0",
+ "version": "4.34.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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 => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[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 ` || '')})`;
+ 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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.
@@ -1,15 +1,16 @@
@@ -1,15 +1,16 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.17.0",
+ "version": "4.34.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.17.0",
+ "version": "4.34.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -17,7 +18,6 @@
@@ -17,7 +18,6 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"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-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"
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"
}
}
@@ -114,7 +114,6 @@
@@ -114,7 +114,6 @@
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
- "dev": true,
"license": "ISC",
"license": "ISC",
"engines": {
"engines": {
"node": ">=20.19.0"
"node": ">=20.19.0"
@@ -215,7 +214,6 @@
@@ -215,7 +214,6 @@
"version": "7.0.0",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"boolbase": "^2.0.0",
"boolbase": "^2.0.0",
@@ -236,7 +234,6 @@
@@ -236,7 +234,6 @@
"version": "8.0.0",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"engines": {
"engines": {
"node": ">=20.19.0"
"node": ">=20.19.0"
@@ -250,7 +247,6 @@
@@ -250,7 +247,6 @@
"version": "0.5.0",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
- "dev": true,
"license": "MIT"
"license": "MIT"
},
},
"node_modules/debug": {
"node_modules/debug": {
@@ -295,7 +291,6 @@
@@ -295,7 +291,6 @@
"version": "3.1.1",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
- "dev": true,
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"domelementtype": "^3.0.0",
"domelementtype": "^3.0.0",
@@ -314,7 +309,6 @@
@@ -314,7 +309,6 @@
"version": "3.0.0",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
- "dev": true,
"funding": [
"funding": [
{
{
"type": "github",
"type": "github",
@@ -330,7 +324,6 @@
@@ -330,7 +324,6 @@
"version": "6.0.1",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"domelementtype": "^3.0.0"
"domelementtype": "^3.0.0"
@@ -347,7 +340,6 @@
@@ -347,7 +340,6 @@
"version": "4.0.2",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"dom-serializer": "^3.0.0",
"dom-serializer": "^3.0.0",
@@ -407,7 +399,6 @@
@@ -407,7 +399,6 @@
"version": "8.1.0",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"engines": {
"engines": {
"node": ">=20.19.0"
"node": ">=20.19.0"
@@ -668,14 +659,12 @@
@@ -668,14 +659,12 @@
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
- "dev": true,
"license": "MIT"
"license": "MIT"
},
},
"node_modules/htmlparser2": {
"node_modules/htmlparser2": {
"version": "10.1.0",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
- "dev": true,
"funding": [
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
"https://github.com/fb55/htmlparser2?sponsor=1",
{
{
@@ -695,7 +684,6 @@
@@ -695,7 +684,6 @@
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
- "dev": true,
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"domelementtype": "^2.3.0",
"domelementtype": "^2.3.0",
@@ -710,7 +698,6 @@
@@ -710,7 +698,6 @@
"version": "4.5.0",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"engines": {
"engines": {
"node": ">=0.12"
"node": ">=0.12"
@@ -723,7 +710,6 @@
@@ -723,7 +710,6 @@
"version": "2.3.0",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
- "dev": true,
"funding": [
"funding": [
{
{
"type": "github",
"type": "github",
@@ -736,7 +722,6 @@
@@ -736,7 +722,6 @@
"version": "5.0.3",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"domelementtype": "^2.3.0"
"domelementtype": "^2.3.0"
@@ -752,7 +737,6 @@
@@ -752,7 +737,6 @@
"version": "3.2.2",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"dom-serializer": "^2.0.0",
"dom-serializer": "^2.0.0",
@@ -767,7 +751,6 @@
@@ -767,7 +751,6 @@
"version": "7.0.1",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"engines": {
"engines": {
"node": ">=0.12"
"node": ">=0.12"
@@ -881,7 +864,6 @@
@@ -881,7 +864,6 @@
"version": "0.18.13",
"version": "0.18.13",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
"integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
"integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
- "dev": true,
"license": "ISC",
"license": "ISC",
"dependencies": {
"dependencies": {
"css-select": "^7.0.0",
"css-select": "^7.0.0",
@@ -1040,7 +1022,6 @@
@@ -1040,7 +1022,6 @@
"version": "3.0.1",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
- "dev": true,
"license": "BSD-2-Clause",
"license": "BSD-2-Clause",
"dependencies": {
"dependencies": {
"boolbase": "^2.0.0"
"boolbase": "^2.0.0"
@@ -1414,7 +1395,6 @@
@@ -1414,7 +1395,6 @@
"version": "0.2.0",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
- "dev": true,
"license": "ISC"
"license": "ISC"
},
},
"node_modules/unpipe": {
"node_modules/unpipe": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -82,7 +82,8 @@
@@ -82,7 +82,8 @@
"./route-transition": "./route-transition.js",
"./route-transition": "./route-transition.js",
"./boost": "./boost.js",
"./boost": "./boost.js",
"./rooms": "./rooms.js",
"./rooms": "./rooms.js",
- "./motion": "./motion.js"
+ "./motion": "./motion.js",
+ "./agent-layer": "./agent-layer.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
@@ -93,7 +94,8 @@
@@ -93,7 +94,8 @@
"icons/",
"icons/",
"social/",
"social/",
"templates/",
"templates/",
- "CHANGELOG.md"
+ "CHANGELOG.md",
+ "docs/agent-layer.md"
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
@@ -110,12 +112,12 @@
@@ -110,12 +112,12 @@
"homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
"homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"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-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"
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0"
}
}
@@ -238,13 +238,13 @@ function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true
@@ -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. */
/** /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()}`, ''];
const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
if (details) out.push(String(details).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;
if (!sec || !sec.links || !sec.links.length) continue;
out.push(`## ${sec.title}`, '');
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('');
out.push('');
}
}
return out.join('\n');
return out.join('\n');
@@ -385,3 +385,8 @@ function feedLinkTags({ rss, atom, title = 'Feed' } = {}) {
@@ -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 };
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);
+}
@@ -139,6 +139,7 @@ function page(o = {}) {
@@ -139,6 +139,7 @@ function page(o = {}) {
'<meta charset="utf-8">',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
head,
head,
+ o.agentLayer && o.canonical ? require('./agent-layer').linkHeaderTags(o.canonical, o.llms).head : '',
summary ? summary.head : '',
summary ? summary.head : '',
o.head || '',
o.head || '',
manifestLinks(o),
manifestLinks(o),
@@ -9,6 +9,6 @@
@@ -9,6 +9,6 @@
},
},
"dependencies": {
"dependencies": {
"express": "^4.22.3",
"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"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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; });