openvibers/openvibe.network Commits
Frame: the composer's / commands and @ mentions (R3) (#864)
Changes
@@@ -1,8 +1,8 @@--# The composer's Frame: models and answers++# The composer's Frame: models, answers, commands and mentionsEvery OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A--site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Both routes--below answer `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.++site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Every route++below answers `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.## `GET /api/frame/choices?task=chat|agent|code|image|research`@@@ -77,3 +77,54 @@ Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Readand `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run iskept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.Asks are rate-limited per person (12 a minute).++++## `GET /api/frame/commands?site=<host>&q=`++++The `/` menu. Every site offers the network's four commands, a site adds its own from its Frame manifest++(`site.commands`), and a signed-in person's saved composer snippets come last as theirs. The site's own come first,++because they are the ones that fit the box; a name appears once, so a site may replace a network command with its own.++`q` filters by the command's name (prefix) or its hint. Each row is `{ name, hint, insert, kind, needs_sign_in }`;++`name` is the word after the slash, `insert` is what a pick puts in the box, `kind` is `network`, `site` or `mine`,++and `needs_sign_in` says the command acts for a person (the composer may offer it and then sign in).++++```json++{++ "site": { "id": "live", "name": "Live", "host": "openvibe.live" },++ "commands": [++ { "name": "clip", "hint": "Make a clip of the stream", "insert": "/clip ", "kind": "site", "needs_sign_in": true },++ { "name": "ask", "hint": "Ask Vibe: an answer with its sources", "insert": "/ask ", "kind": "network", "needs_sign_in": true },++ { "name": "do", "hint": "Hand it to your agent: Actor plans and runs it", "insert": "/do ", "kind": "network", "needs_sign_in": true },++ { "name": "remind", "hint": "Have Today remind you", "insert": "/remind ", "kind": "network", "needs_sign_in": true },++ { "name": "timer", "hint": "Start a focus timer in the Dock", "insert": "/timer ", "kind": "network", "needs_sign_in": true },++ { "name": "sig", "hint": "Signature", "insert": "— Sam", "kind": "mine", "needs_sign_in": false }++ ]++}++```++++`site` may be a hostname, a URL or a site id (`live`); an unknown site answers `site: null` with the network four. A++signed-out answer is `Cache-Control: public, max-age=60` and held a minute; a signed-in one (the snippets need the++person) is `private, no-store` and asked fresh.++++## `GET /api/frame/mentions?q=&site=`++++The `@` menu: public people, the person's own agents from Network's agent directory, and OpenVibe places (open sites++and Live's public channels). At most eight rows, people first, then the person's agents, then places. Signed out it is++public people only. A person row carries the canonical subject id, the public handle, the display name and, only when++the picture is on OpenVibe.Media, the avatar; never an email, a numeric account id, a role or any other account field.++`site` is the host the box is on: that site is not suggested back to itself.++++```json++{++ "mentions": [++ { "kind": "person", "id": "usr_…", "label": "Ana Followed", "handle": "ana",++ "avatar": "https://openvibe.media/p/ana/screenshot", "url": "https://openvibe.network/@ana" },++ { "kind": "agent", "id": "agt_…", "label": "Actor", "sub": "actor · online" },++ { "kind": "site", "id": "live", "label": "Live", "handle": "live", "url": "https://openvibe.live/" },++ { "kind": "channel", "id": "streamer", "label": "Streamer One", "handle": "streamer", "url": "https://openvibe.live/@streamer" }++ ]++}++```++++A person's answer is `private, no-store`; a signed-out one is `public, max-age=60` and held a minute. The read is++rate-limited per actor in memory (120 a minute; a signed-in subject, or a hash of the address when signed out) and++answers `429` with `Retry-After` and `code: "frame.mentions_rate_limited"` past it.nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * The composer's slash commands (composer-post-ask-do.md, R3): GET /api/frame/commands?site=<host>&q=.++ *++ * Every site offers the network's four: /ask (Vibe answers with sources), /do (Actor does it), /remind++ * (Today remembers it) and /timer (the Dock's focus timer). A site adds its own from its manifest's++ * `site.commands` block when it has one, and a signed-in person's saved composer snippets are their++ * own commands. A row is `{ name, hint, insert, kind, needs_sign_in }`:++ *++ * name the word after the slash, lower-case, no slash++ * hint one line the popover shows beside it++ * insert the text a pick puts in the box (the word, a space, ready for its arguments)++ * kind network | site | mine++ * needs_sign_in the command acts for a person, so a signed-out box may offer it and then sign in++ *++ * Site commands come first (the box shows the ones that fit its site first), then the network's, then++ * the person's own. A name appears once: a site may replace a network command of the same name.++ */++const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;++const MAX_COMMANDS = 50;++const MAX_SITE_COMMANDS = 12;++++// The four every box offers. Their hints are the composer's own words, not product names.++const DEFAULTS = Object.freeze([++ { name: 'ask', hint: 'Ask Vibe: an answer with its sources', insert: '/ask ', kind: 'network', needs_sign_in: true },++ { name: 'do', hint: 'Hand it to your agent: Actor plans and runs it', insert: '/do ', kind: 'network', needs_sign_in: true },++ { name: 'remind', hint: 'Have Today remind you', insert: '/remind ', kind: 'network', needs_sign_in: true },++ { name: 'timer', hint: 'Start a focus timer in the Dock', insert: '/timer ', kind: 'network', needs_sign_in: true },++]);++++const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++const nameOf = (v) => String(v == null ? '' : v).trim().replace(/^\/+/, '').toLowerCase();++++/** One row from a manifest command or a saved snippet, or null when it is not a command we can offer. */++function rowOf(raw, kind) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;++ const name = nameOf(raw.name != null ? raw.name : raw.trigger);++ if (!NAME_RE.test(name)) return null;++ const hint = clean(raw.hint != null ? raw.hint : (raw.summary != null ? raw.summary : raw.label), 120);++ const insert = String(raw.insert != null ? raw.insert : raw.text != null ? raw.text : `/${name} `)++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')++ .slice(0, 4000);++ return { name, hint, insert, kind, needs_sign_in: raw.needs_sign_in === true || raw.needsSignIn === true };++}++++/** The site's own commands from its Frame manifest entry (`site.commands`), at most twelve. */++function siteRows(site) {++ const out = [];++ for (const raw of Array.isArray(site && site.commands) ? site.commands.slice(0, MAX_SITE_COMMANDS) : []) {++ const row = rowOf(raw, 'site');++ if (row) out.push(row);++ }++ return out;++}++++/** A person's saved composer snippets (composer.prefs@1 `snippets`) as their own commands. */++function savedRows(prefs) {++ let snippets = null;++ try { snippets = prefs ? JSON.parse(prefs) : null; } catch { snippets = null; }++ const list = snippets && Array.isArray(snippets.snippets) ? snippets.snippets : [];++ const out = [];++ for (const s of list.slice(0, MAX_COMMANDS)) {++ const row = rowOf(s, 'mine');++ if (row) out.push(row);++ }++ return out;++}++++/** The site an asking composer means: a hostname, a URL or a site id (the icon name). */++function siteOf(value, sites, siteForHost) {++ const raw = String(value == null ? '' : value).trim().toLowerCase();++ if (!raw) return null;++ let host = raw;++ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }++ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');++ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));++ if (listed) return listed;++ return typeof siteForHost === 'function' ? siteForHost(host) : null;++}++++/**++ * The body of GET /api/frame/commands.++ * `signedIn` adds the person's saved snippets (`prefs` is their stored composer_prefs JSON).++ */++function read({ site = '', q = '', signedIn = false, prefs = null, sites = [], siteForHost = null } = {}) {++ const found = siteOf(site, sites, siteForHost);++ const query = String(q == null ? '' : q).trim().toLowerCase().slice(0, 50);++ const rows = [];++ const seen = new Set();++ const add = (row) => {++ if (!row || seen.has(row.name) || rows.length >= MAX_COMMANDS) return;++ seen.add(row.name);++ rows.push(row);++ };++ for (const row of siteRows(found)) add(row);++ for (const row of DEFAULTS) add(row);++ if (signedIn) for (const row of savedRows(prefs)) add(row);++ const commands = query++ ? rows.filter((r) => r.name.startsWith(query) || r.hint.toLowerCase().includes(query))++ : rows;++ return {++ site: found ? { id: found.id, name: found.name, host: found.host } : null,++ commands,++ };++}++++module.exports = { read, rowOf, siteOf, savedRows, siteRows, DEFAULTS, NAME_RE, MAX_COMMANDS, MAX_SITE_COMMANDS };nnew file mode 100644@@@ -0,0 +1,266 @@++'use strict';++/**++ * The composer's @ menu (composer-post-ask-do.md, R3): GET /api/frame/mentions?q=&site=.++ *++ * One list for every site, the network's own:++ *++ * person a Network account: its public username, display name and, only when it is an OpenVibe.Media++ * picture, its avatar. Never an email, a numeric id, a role or any other account field.++ * agent one of the person's agents from Network's agent directory (signed in only).++ * site an open OpenVibe place, from the Frame's site list (signed in only).++ * channel a public live channel on OpenVibe.Live (signed in only; Live's public list, never a private row).++ *++ * At most eight rows, people first, then the person's agents, then places. Signed out answers public++ * people only. Signed in, people the person follows and an exact username match come first.++ *++ * The read is rate-limited per actor in this process (a subject when signed in, a hash of the address++ * when not): `/api/frame` already has its per-address limit; this one keeps one person's type-ahead++ * from spending everyone else's.++ */++const crypto = require('crypto');++++const MAX = 8;++const Q_MAX = 50;++const MINUTE = 60_000;++const CHANNELS_MS = 30_000;++const MEDIA_HOST = /(^|\.)openvibe\.media$/i;++const USERNAME_RE = /^[A-Za-z0-9_]{1,40}$/;++++const text = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++++/** An avatar is a public OpenVibe.Media picture, or none: never a Network-served, private or third-party URL. */++function avatarOf(value) {++ const raw = String(value == null ? '' : value);++ if (!raw || raw.length > 500) return null;++ try {++ const u = new URL(raw);++ if (u.protocol !== 'https:' || u.username || u.password) return null;++ return MEDIA_HOST.test(u.hostname) ? raw : null;++ } catch { return null; }++}++++/** The row a person gets: their canonical subject id, public handle, display name and public picture. */++function personRow(u) {++ if (!u || !USERNAME_RE.test(String(u.username || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) return null;++ const avatar = avatarOf(u.avatar_url);++ return {++ kind: 'person',++ id: String(u.subject_id),++ label: text(u.display_name, 120) || u.username,++ handle: u.username,++ ...(avatar ? { avatar } : {}),++ url: `https://openvibe.network/@${encodeURIComponent(u.username)}`,++ };++}++++function agentRow(a) {++ if (!a || !/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.id || ''))) return null;++ const kind = text(a.kind, 20);++ const status = text(a.status, 20);++ return {++ kind: 'agent',++ id: String(a.id),++ label: text(a.name, 120) || 'An agent',++ ...(kind || status ? { sub: [kind, status].filter(Boolean).join(' · ') } : {}),++ };++}++++function siteRow(s, { fullName = (x) => x.name } = {}) {++ if (!s || !s.id || s.status !== 'open' || !s.host) return null;++ return {++ kind: 'site',++ id: String(s.id),++ label: text(s.of ? s.name : fullName(s), 120),++ handle: String(s.id),++ url: `https://${s.host}/`,++ };++}++++function channelRow(st) {++ if (!st || !USERNAME_RE.test(String(st.username || ''))) return null;++ const avatar = avatarOf(st.avatar_url);++ return {++ kind: 'channel',++ id: String(st.username),++ label: text(st.display_name || st.username, 120) || st.username,++ handle: String(st.username),++ ...(avatar ? { avatar } : {}),++ url: `https://openvibe.live/@${encodeURIComponent(st.username)}`,++ };++}++++/** Public Live streams only: a row the site marks unlisted/private never reaches a mention list. */++function publicLive(st) {++ if (!st || typeof st !== 'object') return false;++ if (st.visibility && String(st.visibility).toLowerCase() !== 'public') return false;++ if (st.is_public === false || st.unlisted === true || st.is_unlisted === true || st.listed === false) return false;++ return true;++}++++function createFrameMentions({++ db,++ sites = [],++ siteForHost = null,++ fullName = (s) => s.name,++ liveUrl = '',++ fetchImpl = globalThis.fetch,++ now = Date.now,++ log = console,++ perMinute = 120,++} = {}) {++ const liveBase = String(liveUrl || '').replace(/\/+$/, '');++ const held = new Map(); // signed-out answer, by q++ const hits = new Map(); // actor -> { n, reset }++ let channels = null, channelsAt = 0, channelsInflight = null;++++ async function getJson(url, timeoutMs) {++ if (typeof fetchImpl !== 'function') return null;++ try {++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });++ return r && r.ok ? await r.json() : null;++ } catch (err) {++ if (log && log.warn) log.warn(`[FrameMentions] ${url}: ${String((err && err.message) || err).slice(0, 160)}`);++ return null;++ }++ }++++ /** The public live channels, held 30 s: Live's list is public, its private rows are not our business. */++ async function liveChannels() {++ if (channels && now() - channelsAt < CHANNELS_MS) return channels;++ if (!liveBase) return [];++ channelsInflight = channelsInflight || getJson(`${liveBase}/api/streams`, 2500).then((d) => {++ const streams = d && Array.isArray(d.streams) ? d.streams.filter(publicLive) : [];++ return streams.map(channelRow).filter(Boolean).slice(0, 20);++ }).finally(() => { channelsInflight = null; });++ const rows = await channelsInflight;++ if (rows.length) { channels = rows; channelsAt = now(); }++ return rows;++ }++++ /** The current site, from a hostname, a URL or a site id; null when the composer names none. */++ function siteOf(value) {++ const raw = String(value == null ? '' : value).trim().toLowerCase();++ if (!raw) return null;++ let host = raw;++ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }++ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');++ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));++ if (listed) return listed;++ return typeof siteForHost === 'function' ? siteForHost(host) : null;++ }++++ /** At most eight; people first, then this person's agents, then places. */++ async function get({ q = '', site = '', user = null } = {}) {++ const query = text(q, Q_MAX).toLowerCase();++ const signedIn = Boolean(user && (user.id != null || user.subject_id));++ const current = siteOf(site);++ if (!signedIn) {++ const hit = held.get(query);++ if (hit && now() - hit.at < MINUTE) return { body: hit.body, cached: true };++ const people = await peopleRows(query, null);++ const body = { mentions: people.slice(0, MAX) };++ if (held.size >= 500) held.clear();++ held.set(query, { at: now(), body });++ return { body, cached: false };++ }++++ const out = [];++ const room = () => MAX - out.length;++ for (const row of await peopleRows(query, user)) { if (out.length >= MAX) break; out.push(row); }++ if (room() > 0) for (const row of await agentRows(query, user)) { if (out.length >= MAX) break; out.push(row); }++ if (room() > 0) {++ for (const s of sites) {++ if (out.length >= MAX) break;++ if (!s || s.status !== 'open' || (current && s.id === current.id)) continue;++ if (query && !`${s.id} ${s.name} ${s.host} ${s.tagline || ''}`.toLowerCase().includes(query)) continue;++ const row = siteRow(s, { fullName });++ if (row) out.push(row);++ }++ }++ if (room() > 0) {++ for (const row of await liveChannels()) {++ if (out.length >= MAX) break;++ if (query && !`${row.id} ${row.label}`.toLowerCase().includes(query)) continue;++ out.push(row);++ }++ }++ return { body: { mentions: out.slice(0, MAX) }, cached: false };++ }++++ async function peopleRows(query, user) {++ if (!db) return [];++ const signedIn = Boolean(user && (user.id != null || user.subject_id));++ const params = [];++ let sql = 'SELECT u.id, u.subject_id, u.username, u.display_name, u.avatar_url';++ if (signedIn) {++ sql += ', EXISTS (SELECT 1 FROM follows f WHERE f.follower_id = ? AND f.followed_id = u.id) AS followed';++ params.push(user.id);++ }++ sql += ` FROM users u WHERE u.is_anon = 0 AND COALESCE(u.is_banned, 0) = 0 AND u.merged_into IS NULL++ AND u.deleted_at IS NULL AND u.subject_id IS NOT NULL`;++ if (signedIn) { sql += ' AND u.id <> ?'; params.push(user.id); }++ else sql += ' AND u.profile_public = 1';++ if (query) {++ sql += " AND (position(lower(?) in lower(u.username)) > 0 OR position(lower(?) in lower(coalesce(u.display_name, ''))) > 0)";++ params.push(query, query);++ }++ const order = [];++ if (query) { order.push('(lower(u.username) = lower(?)) DESC'); params.push(query); }++ if (signedIn) order.push('followed DESC');++ order.push('u.username ASC');++ sql += ` ORDER BY ${order.join(', ')} LIMIT ?`;++ params.push(MAX);++ try {++ const rows = await db.prepare(sql).all(...params);++ return rows.map(personRow).filter(Boolean).slice(0, MAX);++ } catch (err) {++ if (log && log.warn) log.warn(`[FrameMentions] people: ${String((err && err.message) || err).slice(0, 160)}`);++ return [];++ }++ }++++ async function agentRows(query, user) {++ if (!db || !user) return [];++ let subject = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || '')) ? String(user.subject_id) : '';++ if (!subject && user.id != null) {++ try { const row = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id); subject = (row && row.subject_id) || ''; } catch { subject = ''; }++ }++ if (!subject) return [];++ const params = [subject];++ let sql = 'SELECT id, kind, name, service, machine_id, status FROM agent_directory WHERE owner_subject = ?';++ if (query) { sql += ' AND position(lower(?) in lower(name)) > 0'; params.push(query); }++ sql += " ORDER BY CASE status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, updated_at DESC, id ASC LIMIT ?";++ params.push(MAX);++ try {++ const rows = await db.prepare(sql).all(...params);++ return rows.map(agentRow).filter(Boolean).slice(0, MAX);++ } catch (err) {++ if (log && log.warn) log.warn(`[FrameMentions] agents: ${String((err && err.message) || err).slice(0, 160)}`);++ return [];++ }++ }++++ /** The per-actor window: 120 a minute for one person, one hashed address when signed out. */++ function allow(actor) {++ const at = now();++ const key = String(actor || '');++ if (!key) return { ok: true };++ let w = hits.get(key);++ if (!w || at >= w.reset) { w = { n: 0, reset: at + MINUTE }; hits.set(key, w); }++ if (w.n >= perMinute) return { ok: false, retryAfter: Math.max(1, Math.ceil((w.reset - at) / 1000)) };++ w.n++;++ if (hits.size > 20_000) for (const [k, v] of hits) if (at >= v.reset) hits.delete(k);++ return { ok: true };++ }++++ /** The address a signed-out request is counted by: a short hash in memory, never the address itself. */++ function actorKey(req, user) {++ if (user && (user.subject_id || user.id != null)) return `user:${user.subject_id || user.id}`;++ const ip = String((req && req.ip) || '');++ return `ip:${crypto.createHash('sha256').update(ip).digest('hex').slice(0, 20)}`;++ }++++ return { get, allow, actorKey, siteOf, _state: () => ({ held: held.size, hits: hits.size }) };++}++++module.exports = { createFrameMentions, avatarOf, personRow, agentRow, siteRow, channelRow, publicLive, MAX, MINUTE };@@@ -4,6 +4,8 @@//// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }++// GET /api/frame/commands?site=&q= the composer's / menu: site commands, network defaults, a person's own++// GET /api/frame/mentions?q=&site= the composer's @ menu: public people, the person's agents, places (max 8)// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; no cache)// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted@@@ -32,6 +34,8 @@ const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERconst { createMenuCards } = require('./menu-cards');const { createFrameChoices } = require('./choices');const { createFrameAsk } = require('./ask');++const { read: readCommands } = require('./commands');++const { createFrameMentions } = require('./mentions');const seen = require('./seen');const toolsCatalog = require('../domains/catalog');@@@ -165,7 +169,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {};}--async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, today = null, now = Date.now } = {}) {++async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, person = null, today = null, now = Date.now, sites = SITES, places = LISTED, mentionLimit = 120 } = {}) {const services = (config && config.services) || {};aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';const choices = createFrameChoices({@@@ -239,6 +243,23 @@ async function createFrameService(db, config, analytics, { privateKey = null, istry { const r = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); return r.ok ? await r.json() : null; } catch { return null; }}++ // The composer's Reach (composer-post-ask-do.md, R3): the site's slash commands and the @ menu. Both++ // read a person's session when one is sent (Bearer or the Network cookie) and answer as a signed-out++ // visitor otherwise; neither keeps a person's own answer.++ const sessionAuth = require('../auth/session');++ async function defaultPerson(req) {++ const token = sessionAuth.sessionToken(req);++ if (!token) return null;++ const publicKey = req.app && req.app.locals && req.app.locals.publicKey;++ if (!publicKey || !config || !config.jwt || !config.jwt.issuer) return null;++ try { const out = await sessionAuth.verifySession(token, { db, publicKey, config }); return out && !out.error ? out.user : null; } catch { return null; }++ }++ const personOf = typeof person === 'function' ? person : defaultPerson;++ const mentions = createFrameMentions({++ db, sites: places, siteForHost, fullName, now, perMinute: mentionLimit,++ liveUrl: svcUrl('live', 'http://127.0.0.1:3000'),++ });++async function refreshRank() {const scores = Object.assign({}, rank.scores);// Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only).@@@ -1018,6 +1039,52 @@ async function createFrameService(db, config, analytics, { privateKey = null, isres.send(JSON.stringify(got.body));});++ // GET /api/frame/commands?site=<host>&q= — the composer's slash commands (composer-post-ask-do.md, R3):++ // every site's /ask, /do, /remind and /timer, the site's own from its manifest, and a signed-in person's++ // saved snippets. Site commands come first, a name appears once, and q filters by name or hint.++ // Signed out the answer is public and held 60 s; signed in it is the person's own and never kept.++ const commandHeld = new Map();++ router.get('/commands', async (req, res) => {++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });++ const site = String(req.query.site || '').slice(0, 255);++ const q = String(req.query.q || '').slice(0, 50);++ const user = await personOf(req).catch(() => null);++ let prefs = null;++ if (user && user.id != null) {++ try { const row = await db.prepare('SELECT composer_prefs FROM user_preferences WHERE user_id = ?').get(user.id); prefs = row ? row.composer_prefs : null; } catch { prefs = null; }++ }++ if (user) {++ res.set('Cache-Control', 'private, no-store');++ } else {++ const key = `${site.toLowerCase()}|${q.toLowerCase()}`;++ const kept = commandHeld.get(key);++ res.set('Cache-Control', 'public, max-age=60');++ if (kept && now() - kept.at < 60_000) return res.send(kept.body);++ const body = JSON.stringify(readCommands({ site, q, prefs: null, sites, siteForHost }));++ if (commandHeld.size >= 500) commandHeld.clear();++ commandHeld.set(key, { at: now(), body });++ return res.send(body);++ }++ res.send(JSON.stringify(readCommands({ site, q, signedIn: true, prefs, sites, siteForHost })));++ });++++ // GET /api/frame/mentions?q=&site= — the composer's @ menu (composer-post-ask-do.md, R3): public people++ // first, then the person's own agents from the agent directory, then OpenVibe's places (open sites and++ // Live's public channels). At most eight; signed out it is public people only. Rate-limited per actor in++ // memory (a signed-in subject, or a hash of the address), never an email or a private account field.++ router.get('/mentions', async (req, res) => {++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });++ const user = await personOf(req).catch(() => null);++ const allowed = mentions.allow(mentions.actorKey(req, user));++ if (!allowed.ok) {++ res.set({ 'Cache-Control': 'private, no-store', 'Retry-After': String(allowed.retryAfter) });++ return res.status(429).json({ error: 'Too many mention searches: try again in a minute', code: 'frame.mentions_rate_limited' });++ }++ const got = await mentions.get({ q: String(req.query.q || '').slice(0, 50), site: String(req.query.site || '').slice(0, 255), user });++ res.set('Cache-Control', user ? 'private, no-store' : 'public, max-age=60');++ res.send(JSON.stringify(got.body));++ });++// POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the// chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at// /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.@@@ -22,6 +22,9 @@ const RAW_SITES = contracts.services.manifests.filter((m) => m.site && !HIDDEN_S// A site's own footer-strip items (label, icon, link, and a number or a line), at most three. They are// validated again at the read (server/frame/service.js stripOf): text only, an https link on an OpenVibe domain....(Array.isArray(m.site.strip) ? { strip: m.site.strip } : {}),++ // The site's own composer commands (composer-post-ask-do.md, R3), validated again at the read++ // (server/frame/commands.js siteRows): a slash word, a one-line hint and what a pick inserts.++ ...(Array.isArray(m.site.commands) ? { commands: m.site.commands } : {}),...(m.site.host ? { host: m.site.host } : {}),})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);function manifestHost(service) {@@@ -570,7 +570,7 @@ const ACCOUNT_ID_LINK = {const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.--const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes).';++const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes). The composer\'s `/` menu is `GET /api/frame/commands?site=<host>&q=` (the site\'s own, the network\'s /ask, /do, /remind and /timer, and a signed-in person\'s saved commands) and its `@` menu is `GET /api/frame/mentions?q=&site=` (public people first, then the person\'s agents and OpenVibe places, at most eight).';function llmsTxt() {const { catalog } = toolsCatalog.peek();@@@ -590,7 +590,7 @@ function llmsTxt() {{ title: 'Machine-readable', links: [{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },-- { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages) and POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events' },++ { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages), POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events, GET /api/frame/commands for the / menu and GET /api/frame/mentions for the @ menu' },{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// GET /api/frame/commands: every site's /ask, /do, /remind and /timer, a site's own from its Frame manifest, and a++// signed-in person's saved composer snippets. Site commands first, a name once, q filtering, and the cache rule++// (signed out public for 60 s, signed in private and never kept).++const assert = require('assert');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++const { read: readCommands } = require('../server/frame/commands');++++(async () => {++ const db = getDb();++ const subject = ids.newId('user');++ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, profile_public)++ VALUES ('sam', 'Sam Rivera', '[email protected]', 'x', ?, 1) RETURNING id`).get(subject);++ const me = { id: Number(row.id), subject_id: subject, username: 'sam' };++ await db.prepare(`INSERT INTO user_preferences (user_id, composer_prefs, composer_rev) VALUES (?, ?, 2)`)++ .run(me.id, JSON.stringify({ snippets: [++ { trigger: '/sig', label: 'Signature', text: '— Sam' },++ { trigger: '/standup', label: 'Stand-up notes', text: 'Yesterday: ' },++ { trigger: 'not a slash', label: 'bad', text: 'x' },++ ] }));++++ const sites = [{++ id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes',++ commands: [++ { name: 'poll', summary: 'Ask the room a question', insert: '/poll ', needs_sign_in: true },++ { name: 'ask', hint: 'Ask the community', insert: '/ask ' },++ { name: 'Bad Name', hint: 'not a slash word', insert: '/bad ' },++ ],++ }];++++ let clock = 1_000_000;++ let who = null;++ const svc = await createFrameService(db, { services: {} }, null, {++ person: async () => who, sites, places: sites, now: () => clock, mentionLimit: 120,++ });++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/frame/commands`;++ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };++++ try {++ // Signed out: the site's own first (it replaces /ask), then the network's four; no saved commands.++ let { r, d } = await get('?site=openvibe.community');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.match(r.headers.get('vary') || '', /Authorization/);++ assert.deepStrictEqual(d.site, { id: 'community', name: 'Community', host: 'openvibe.community' });++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'do', 'remind', 'timer'],++ 'the site first, its /ask replaces the network one, then the network four');++ assert.deepStrictEqual(Object.keys(d.commands[0]).sort(), ['hint', 'insert', 'kind', 'name', 'needs_sign_in']);++ assert.deepStrictEqual([d.commands[0].kind, d.commands[0].needs_sign_in, d.commands[0].hint], ['site', true, 'Ask the room a question']);++ assert.deepStrictEqual([d.commands[1].kind, d.commands[1].hint], ['site', 'Ask the community']);++ assert.deepStrictEqual(d.commands.slice(2).map((c) => c.kind), ['network', 'network', 'network']);++ assert.strictEqual(d.commands.find((c) => c.name === 'do').insert, '/do ');++ assert.ok(!d.commands.some((c) => c.name === 'sig'), 'saved commands need a person');++++ // The same signed-out answer is held 60 s, then the site manifest is read again.++ sites[0].commands.push({ name: 'clip', hint: 'Make a clip' });++ ({ d } = await get('?site=openvibe.community'));++ assert.ok(!d.commands.some((c) => c.name === 'clip'), 'the signed-out answer is held 60 s');++ clock += 61_000;++ ({ d } = await get('?site=openvibe.community'));++ assert.ok(d.commands.some((c) => c.name === 'clip'), 'after the minute the manifest is read again');++++ // q filters by the name and the hint; an unknown site still offers the network four.++ ({ d } = await get('?site=community&q=poll'));++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll']);++ ({ d } = await get('?site=community&q=SIGN'));++ assert.deepStrictEqual(d.commands, [], 'a query that matches no name or hint is an empty list');++ ({ d } = await get('?site=nope.example'));++ assert.strictEqual(d.site, null);++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['ask', 'do', 'remind', 'timer']);++++ // Signed in: the person's own snippets come last, and nothing is kept.++ who = me;++ ({ r, d } = await get('?site=openvibe.community'));++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'clip', 'do', 'remind', 'timer', 'sig', 'standup']);++ const mine = d.commands.find((c) => c.name === 'sig');++ assert.deepStrictEqual(mine, { name: 'sig', hint: 'Signature', insert: '— Sam', kind: 'mine', needs_sign_in: false });++ assert.ok(!d.commands.some((c) => c.name === 'not a slash'), 'an invalid snippet is not a command');++ ({ d } = await get('?site=community&q=standup'));++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['standup']);++++ // The pure read is the same rule: an invalid manifest command is dropped.++ const pure = readCommands({ site: 'community', sites, siteForHost: () => null });++ assert.ok(!pure.commands.some((c) => c.name === 'bad name'));++ assert.strictEqual(pure.commands[0].name, 'poll');++++ console.log(' ok frame commands: site, network and saved rows; signed-out cache; q\n');++ } finally {++ await new Promise((r2) => server.close(r2));++ svc.stop();++ }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++// GET /api/frame/mentions: public people first (avatar only from openvibe.media), then the person's own agents from++// the agent directory, then open sites and Live's public channels. At most eight; signed out it is public people only.++// The per-actor limit, the signed-out 60 s cache and the absence of any private account field are checked too.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++(async () => {++ const db = getDb();++ const addUser = async (username, extra = {}) => {++ const subject = ids.newId('user');++ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, is_anon, is_banned, profile_public, avatar_url)++ VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?) RETURNING id`)++ .get(username, extra.display || username, extra.email || null, subject, extra.anon ? 1 : 0, extra.banned ? 1 : 0,++ extra.public === false ? 0 : 1, extra.avatar || null);++ return { id: Number(row.id), subject_id: subject, username, display_name: extra.display || username };++ };++ const me = await addUser('sam', { display: 'Sam Rivera', email: '[email protected]' });++ const ana = await addUser('ana', { display: 'Ana Followed', email: '[email protected]', avatar: 'https://openvibe.media/p/ana/screenshot' });++ const anabel = await addUser('anabel', { display: 'Anabel Also', email: '[email protected]' });++ await addUser('zoe', { display: 'Zoe Public', email: '[email protected]', avatar: 'https://openvibe.network/avatar/zoe?s=64' });++ await addUser('hidden', { display: 'Hidden Person', public: false });++ await addUser('banned', { display: 'Banned Person', banned: true });++ await addUser('anon', { display: 'Anon Person', anon: true });++ const other = await addUser('other', { display: 'Other Person' });++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, ana.id);++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, anabel.id);++++ const agent = async (owner, name, kind, status) => {++ const id = ids.newId('agent');++ await db.prepare(`INSERT INTO agent_directory (id, owner_subject, kind, name, service, skills, status)++ VALUES (?, ?, ?, ?, ?, '[]', ?)`).run(id, owner.subject_id, kind, name, kind, status);++ return id;++ };++ const actor = await agent(me, 'Actor', 'actor', 'online');++ await agent(me, 'Codes', 'codes', 'offline');++ await agent(other, 'Other Agent', 'actor', 'online');++++ const live = http.createServer((req, res) => {++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ streams: [++ { username: 'streamer', display_name: 'Streamer One', avatar_url: 'https://openvibe.media/p/streamer/screenshot', is_public: true },++ { username: 'secret', display_name: 'Secret Channel', visibility: 'private' },++ { username: 'unlisted', display_name: 'Unlisted Channel', unlisted: true },++ { username: 'bad name', display_name: 'Bad Channel', is_public: true },++ ] }));++ });++ await new Promise((r) => live.listen(0, '127.0.0.1', r));++++ const places = [++ { id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes' },++ { id: 'live', name: 'Live', host: 'openvibe.live', status: 'open', tagline: 'Go live, watch streams' },++ { id: 'network', name: 'Network', host: 'openvibe.network', status: 'open', tagline: 'Your account' },++ { id: 'soon', name: 'Soon', host: 'openvibe.soon', status: 'soon' },++ ];++++ let clock = 1_000_000;++ let who = null;++ const svc = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {++ person: async () => who, places, sites: places, now: () => clock, mentionLimit: 50,++ });++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/frame/mentions`;++ const get = async (qs) => { const r = await fetch(`${base}${qs}`); const text = await r.text(); return { r, text, d: JSON.parse(text) }; };++++ try {++ // Signed out: public people only, in the mention row shape; the 60 s public cache.++ let { r, text, d } = await get('?q=ana');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.match(r.headers.get('vary') || '', /Authorization/);++ assert.deepStrictEqual(d.mentions.map((m) => m.handle), ['ana', 'anabel'], 'an exact username first, then the rest');++ assert.deepStrictEqual(d.mentions[0], {++ kind: 'person', id: ana.subject_id, label: 'Ana Followed', handle: 'ana',++ avatar: 'https://openvibe.media/p/ana/screenshot', url: 'https://openvibe.network/@ana',++ });++ assert.ok(d.mentions.every((m) => m.kind === 'person'), 'signed out has no agents or places');++++ ({ d } = await get('?q=zoe'));++ assert.strictEqual(d.mentions.length, 1);++ assert.ok(!('avatar' in d.mentions[0]), 'a non-Media avatar is never returned');++ assert.ok(!/zoe@example\.test|sam@example\.test/.test(text), 'no email');++++ for (const q of ['hidden', 'banned', 'anon']) {++ ({ d } = await get(`?q=${q}`));++ assert.deepStrictEqual(d.mentions, [], `${q} is not a public person to mention`);++ }++++ // Signed in: people first, then the person's agents (never another owner's), then places and channels.++ who = me;++ ({ r, d } = await get('?q=actor'));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['agent', actor]]);++ assert.strictEqual(d.mentions[0].label, 'Actor');++ assert.match(d.mentions[0].sub, /actor.*online/i);++ assert.ok(!text.includes(other.subject_id) && !text.includes('Other Agent'), "another owner's agent is not listed");++++ ({ d } = await get('?q=live'));++ assert.ok(d.mentions.some((m) => m.kind === 'site' && m.id === 'live' && m.url === 'https://openvibe.live/'));++ assert.ok(!d.mentions.some((m) => m.id === 'soon'), 'a site that is not open is not a place');++++ ({ d } = await get('?q=streamer'));++ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['channel', 'streamer']]);++ assert.strictEqual(d.mentions[0].url, 'https://openvibe.live/@streamer');++ assert.strictEqual(d.mentions[0].avatar, 'https://openvibe.media/p/streamer/screenshot');++ ({ d } = await get('?q=secret'));++ assert.deepStrictEqual(d.mentions, [], 'a private Live channel is never mentioned');++ ({ d } = await get('?q=unlisted'));++ assert.deepStrictEqual(d.mentions, [], 'an unlisted Live channel is never mentioned');++++ ({ d } = await get('?site=openvibe.community&q=community'));++ assert.ok(!d.mentions.some((m) => m.id === 'community'), 'the site the box is on is not suggested back');++++ ({ d } = await get('?q='));++ assert.ok(d.mentions.length <= 8, 'at most eight rows');++ const rank = { person: 0, agent: 1, site: 2, channel: 3 };++ const ranks = d.mentions.map((m) => rank[m.kind]);++ assert.deepStrictEqual(ranks, [...ranks].sort((a, b) => a - b), 'people first, then agents, then places');++ assert.ok(!/@example\.test/.test(JSON.stringify(d)), 'no email in the signed-in answer');++ assert.ok(!/password|owner_subject|machine_id|endpoint|skills/.test(JSON.stringify(d)), 'no private agent field');++++ // The per-actor limit is in memory and resets on the next minute; Retry-After says when.++ const limited = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {++ person: async () => me, places, sites: places, now: () => clock, mentionLimit: 2,++ });++ const app2 = express();++ app2.set('trust proxy', 'loopback');++ app2.use('/api/frame', limited.router);++ const server2 = app2.listen(0, '127.0.0.1');++ await new Promise((r2) => server2.once('listening', r2));++ const base2 = `http://127.0.0.1:${server2.address().port}/api/frame/mentions`;++ try {++ assert.strictEqual((await fetch(`${base2}?q=a`)).status, 200);++ assert.strictEqual((await fetch(`${base2}?q=b`)).status, 200);++ const third = await fetch(`${base2}?q=c`);++ assert.strictEqual(third.status, 429);++ assert.ok(Number(third.headers.get('retry-after')) >= 1);++ assert.strictEqual((await third.json()).code, 'frame.mentions_rate_limited');++ clock += 61_000;++ assert.strictEqual((await fetch(`${base2}?q=d`)).status, 200, 'the window is a minute');++ } finally {++ await new Promise((r2) => server2.close(r2));++ limited.stop();++ }++++ console.log(' ok frame mentions: people, agents, places, ranking, signed-out cache and the per-actor limit\n');++ } finally {++ await new Promise((r2) => server.close(r2));++ live.close();++ svc.stop();++ }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -133,6 +133,7 @@ const REVIEWED = {'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],++ 'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
-# The composer's Frame: models and answers
+# The composer's Frame: models, answers, commands and mentions
Every OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A
Every OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A
-site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Both routes
-below answer `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.
+site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Every route
+below answers `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.
## `GET /api/frame/choices?task=chat|agent|code|image|research`
## `GET /api/frame/choices?task=chat|agent|code|image|research`
@@ -77,3 +77,54 @@ Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Read
@@ -77,3 +77,54 @@ Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Read
and `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run is
and `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run is
kept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.
kept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.
Asks are rate-limited per person (12 a minute).
Asks are rate-limited per person (12 a minute).
+
+## `GET /api/frame/commands?site=<host>&q=`
+
+The `/` menu. Every site offers the network's four commands, a site adds its own from its Frame manifest
+(`site.commands`), and a signed-in person's saved composer snippets come last as theirs. The site's own come first,
+because they are the ones that fit the box; a name appears once, so a site may replace a network command with its own.
+`q` filters by the command's name (prefix) or its hint. Each row is `{ name, hint, insert, kind, needs_sign_in }`;
+`name` is the word after the slash, `insert` is what a pick puts in the box, `kind` is `network`, `site` or `mine`,
+and `needs_sign_in` says the command acts for a person (the composer may offer it and then sign in).
+
+```json
+{
+ "site": { "id": "live", "name": "Live", "host": "openvibe.live" },
+ "commands": [
+ { "name": "clip", "hint": "Make a clip of the stream", "insert": "/clip ", "kind": "site", "needs_sign_in": true },
+ { "name": "ask", "hint": "Ask Vibe: an answer with its sources", "insert": "/ask ", "kind": "network", "needs_sign_in": true },
+ { "name": "do", "hint": "Hand it to your agent: Actor plans and runs it", "insert": "/do ", "kind": "network", "needs_sign_in": true },
+ { "name": "remind", "hint": "Have Today remind you", "insert": "/remind ", "kind": "network", "needs_sign_in": true },
+ { "name": "timer", "hint": "Start a focus timer in the Dock", "insert": "/timer ", "kind": "network", "needs_sign_in": true },
+ { "name": "sig", "hint": "Signature", "insert": "— Sam", "kind": "mine", "needs_sign_in": false }
+ ]
+}
+```
+
+`site` may be a hostname, a URL or a site id (`live`); an unknown site answers `site: null` with the network four. A
+signed-out answer is `Cache-Control: public, max-age=60` and held a minute; a signed-in one (the snippets need the
+person) is `private, no-store` and asked fresh.
+
+## `GET /api/frame/mentions?q=&site=`
+
+The `@` menu: public people, the person's own agents from Network's agent directory, and OpenVibe places (open sites
+and Live's public channels). At most eight rows, people first, then the person's agents, then places. Signed out it is
+public people only. A person row carries the canonical subject id, the public handle, the display name and, only when
+the picture is on OpenVibe.Media, the avatar; never an email, a numeric account id, a role or any other account field.
+`site` is the host the box is on: that site is not suggested back to itself.
+
+```json
+{
+ "mentions": [
+ { "kind": "person", "id": "usr_…", "label": "Ana Followed", "handle": "ana",
+ "avatar": "https://openvibe.media/p/ana/screenshot", "url": "https://openvibe.network/@ana" },
+ { "kind": "agent", "id": "agt_…", "label": "Actor", "sub": "actor · online" },
+ { "kind": "site", "id": "live", "label": "Live", "handle": "live", "url": "https://openvibe.live/" },
+ { "kind": "channel", "id": "streamer", "label": "Streamer One", "handle": "streamer", "url": "https://openvibe.live/@streamer" }
+ ]
+}
+```
+
+A person's answer is `private, no-store`; a signed-out one is `public, max-age=60` and held a minute. The read is
+rate-limited per actor in memory (120 a minute; a signed-in subject, or a hash of the address when signed out) and
+answers `429` with `Retry-After` and `code: "frame.mentions_rate_limited"` past it.
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * The composer's slash commands (composer-post-ask-do.md, R3): GET /api/frame/commands?site=<host>&q=.
+ *
+ * Every site offers the network's four: /ask (Vibe answers with sources), /do (Actor does it), /remind
+ * (Today remembers it) and /timer (the Dock's focus timer). A site adds its own from its manifest's
+ * `site.commands` block when it has one, and a signed-in person's saved composer snippets are their
+ * own commands. A row is `{ name, hint, insert, kind, needs_sign_in }`:
+ *
+ * name the word after the slash, lower-case, no slash
+ * hint one line the popover shows beside it
+ * insert the text a pick puts in the box (the word, a space, ready for its arguments)
+ * kind network | site | mine
+ * needs_sign_in the command acts for a person, so a signed-out box may offer it and then sign in
+ *
+ * Site commands come first (the box shows the ones that fit its site first), then the network's, then
+ * the person's own. A name appears once: a site may replace a network command of the same name.
+ */
+const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
+const MAX_COMMANDS = 50;
+const MAX_SITE_COMMANDS = 12;
+
+// The four every box offers. Their hints are the composer's own words, not product names.
+const DEFAULTS = Object.freeze([
+ { name: 'ask', hint: 'Ask Vibe: an answer with its sources', insert: '/ask ', kind: 'network', needs_sign_in: true },
+ { name: 'do', hint: 'Hand it to your agent: Actor plans and runs it', insert: '/do ', kind: 'network', needs_sign_in: true },
+ { name: 'remind', hint: 'Have Today remind you', insert: '/remind ', kind: 'network', needs_sign_in: true },
+ { name: 'timer', hint: 'Start a focus timer in the Dock', insert: '/timer ', kind: 'network', needs_sign_in: true },
+]);
+
+const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+const nameOf = (v) => String(v == null ? '' : v).trim().replace(/^\/+/, '').toLowerCase();
+
+/** One row from a manifest command or a saved snippet, or null when it is not a command we can offer. */
+function rowOf(raw, kind) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const name = nameOf(raw.name != null ? raw.name : raw.trigger);
+ if (!NAME_RE.test(name)) return null;
+ const hint = clean(raw.hint != null ? raw.hint : (raw.summary != null ? raw.summary : raw.label), 120);
+ const insert = String(raw.insert != null ? raw.insert : raw.text != null ? raw.text : `/${name} `)
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
+ .slice(0, 4000);
+ return { name, hint, insert, kind, needs_sign_in: raw.needs_sign_in === true || raw.needsSignIn === true };
+}
+
+/** The site's own commands from its Frame manifest entry (`site.commands`), at most twelve. */
+function siteRows(site) {
+ const out = [];
+ for (const raw of Array.isArray(site && site.commands) ? site.commands.slice(0, MAX_SITE_COMMANDS) : []) {
+ const row = rowOf(raw, 'site');
+ if (row) out.push(row);
+ }
+ return out;
+}
+
+/** A person's saved composer snippets (composer.prefs@1 `snippets`) as their own commands. */
+function savedRows(prefs) {
+ let snippets = null;
+ try { snippets = prefs ? JSON.parse(prefs) : null; } catch { snippets = null; }
+ const list = snippets && Array.isArray(snippets.snippets) ? snippets.snippets : [];
+ const out = [];
+ for (const s of list.slice(0, MAX_COMMANDS)) {
+ const row = rowOf(s, 'mine');
+ if (row) out.push(row);
+ }
+ return out;
+}
+
+/** The site an asking composer means: a hostname, a URL or a site id (the icon name). */
+function siteOf(value, sites, siteForHost) {
+ const raw = String(value == null ? '' : value).trim().toLowerCase();
+ if (!raw) return null;
+ let host = raw;
+ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }
+ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');
+ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));
+ if (listed) return listed;
+ return typeof siteForHost === 'function' ? siteForHost(host) : null;
+}
+
+/**
+ * The body of GET /api/frame/commands.
+ * `signedIn` adds the person's saved snippets (`prefs` is their stored composer_prefs JSON).
+ */
+function read({ site = '', q = '', signedIn = false, prefs = null, sites = [], siteForHost = null } = {}) {
+ const found = siteOf(site, sites, siteForHost);
+ const query = String(q == null ? '' : q).trim().toLowerCase().slice(0, 50);
+ const rows = [];
+ const seen = new Set();
+ const add = (row) => {
+ if (!row || seen.has(row.name) || rows.length >= MAX_COMMANDS) return;
+ seen.add(row.name);
+ rows.push(row);
+ };
+ for (const row of siteRows(found)) add(row);
+ for (const row of DEFAULTS) add(row);
+ if (signedIn) for (const row of savedRows(prefs)) add(row);
+ const commands = query
+ ? rows.filter((r) => r.name.startsWith(query) || r.hint.toLowerCase().includes(query))
+ : rows;
+ return {
+ site: found ? { id: found.id, name: found.name, host: found.host } : null,
+ commands,
+ };
+}
+
+module.exports = { read, rowOf, siteOf, savedRows, siteRows, DEFAULTS, NAME_RE, MAX_COMMANDS, MAX_SITE_COMMANDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
+'use strict';
+/**
+ * The composer's @ menu (composer-post-ask-do.md, R3): GET /api/frame/mentions?q=&site=.
+ *
+ * One list for every site, the network's own:
+ *
+ * person a Network account: its public username, display name and, only when it is an OpenVibe.Media
+ * picture, its avatar. Never an email, a numeric id, a role or any other account field.
+ * agent one of the person's agents from Network's agent directory (signed in only).
+ * site an open OpenVibe place, from the Frame's site list (signed in only).
+ * channel a public live channel on OpenVibe.Live (signed in only; Live's public list, never a private row).
+ *
+ * At most eight rows, people first, then the person's agents, then places. Signed out answers public
+ * people only. Signed in, people the person follows and an exact username match come first.
+ *
+ * The read is rate-limited per actor in this process (a subject when signed in, a hash of the address
+ * when not): `/api/frame` already has its per-address limit; this one keeps one person's type-ahead
+ * from spending everyone else's.
+ */
+const crypto = require('crypto');
+
+const MAX = 8;
+const Q_MAX = 50;
+const MINUTE = 60_000;
+const CHANNELS_MS = 30_000;
+const MEDIA_HOST = /(^|\.)openvibe\.media$/i;
+const USERNAME_RE = /^[A-Za-z0-9_]{1,40}$/;
+
+const text = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+
+/** An avatar is a public OpenVibe.Media picture, or none: never a Network-served, private or third-party URL. */
+function avatarOf(value) {
+ const raw = String(value == null ? '' : value);
+ if (!raw || raw.length > 500) return null;
+ try {
+ const u = new URL(raw);
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
+ return MEDIA_HOST.test(u.hostname) ? raw : null;
+ } catch { return null; }
+}
+
+/** The row a person gets: their canonical subject id, public handle, display name and public picture. */
+function personRow(u) {
+ if (!u || !USERNAME_RE.test(String(u.username || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) return null;
+ const avatar = avatarOf(u.avatar_url);
+ return {
+ kind: 'person',
+ id: String(u.subject_id),
+ label: text(u.display_name, 120) || u.username,
+ handle: u.username,
+ ...(avatar ? { avatar } : {}),
+ url: `https://openvibe.network/@${encodeURIComponent(u.username)}`,
+ };
+}
+
+function agentRow(a) {
+ if (!a || !/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.id || ''))) return null;
+ const kind = text(a.kind, 20);
+ const status = text(a.status, 20);
+ return {
+ kind: 'agent',
+ id: String(a.id),
+ label: text(a.name, 120) || 'An agent',
+ ...(kind || status ? { sub: [kind, status].filter(Boolean).join(' · ') } : {}),
+ };
+}
+
+function siteRow(s, { fullName = (x) => x.name } = {}) {
+ if (!s || !s.id || s.status !== 'open' || !s.host) return null;
+ return {
+ kind: 'site',
+ id: String(s.id),
+ label: text(s.of ? s.name : fullName(s), 120),
+ handle: String(s.id),
+ url: `https://${s.host}/`,
+ };
+}
+
+function channelRow(st) {
+ if (!st || !USERNAME_RE.test(String(st.username || ''))) return null;
+ const avatar = avatarOf(st.avatar_url);
+ return {
+ kind: 'channel',
+ id: String(st.username),
+ label: text(st.display_name || st.username, 120) || st.username,
+ handle: String(st.username),
+ ...(avatar ? { avatar } : {}),
+ url: `https://openvibe.live/@${encodeURIComponent(st.username)}`,
+ };
+}
+
+/** Public Live streams only: a row the site marks unlisted/private never reaches a mention list. */
+function publicLive(st) {
+ if (!st || typeof st !== 'object') return false;
+ if (st.visibility && String(st.visibility).toLowerCase() !== 'public') return false;
+ if (st.is_public === false || st.unlisted === true || st.is_unlisted === true || st.listed === false) return false;
+ return true;
+}
+
+function createFrameMentions({
+ db,
+ sites = [],
+ siteForHost = null,
+ fullName = (s) => s.name,
+ liveUrl = '',
+ fetchImpl = globalThis.fetch,
+ now = Date.now,
+ log = console,
+ perMinute = 120,
+} = {}) {
+ const liveBase = String(liveUrl || '').replace(/\/+$/, '');
+ const held = new Map(); // signed-out answer, by q
+ const hits = new Map(); // actor -> { n, reset }
+ let channels = null, channelsAt = 0, channelsInflight = null;
+
+ async function getJson(url, timeoutMs) {
+ if (typeof fetchImpl !== 'function') return null;
+ try {
+ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
+ return r && r.ok ? await r.json() : null;
+ } catch (err) {
+ if (log && log.warn) log.warn(`[FrameMentions] ${url}: ${String((err && err.message) || err).slice(0, 160)}`);
+ return null;
+ }
+ }
+
+ /** The public live channels, held 30 s: Live's list is public, its private rows are not our business. */
+ async function liveChannels() {
+ if (channels && now() - channelsAt < CHANNELS_MS) return channels;
+ if (!liveBase) return [];
+ channelsInflight = channelsInflight || getJson(`${liveBase}/api/streams`, 2500).then((d) => {
+ const streams = d && Array.isArray(d.streams) ? d.streams.filter(publicLive) : [];
+ return streams.map(channelRow).filter(Boolean).slice(0, 20);
+ }).finally(() => { channelsInflight = null; });
+ const rows = await channelsInflight;
+ if (rows.length) { channels = rows; channelsAt = now(); }
+ return rows;
+ }
+
+ /** The current site, from a hostname, a URL or a site id; null when the composer names none. */
+ function siteOf(value) {
+ const raw = String(value == null ? '' : value).trim().toLowerCase();
+ if (!raw) return null;
+ let host = raw;
+ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }
+ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');
+ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));
+ if (listed) return listed;
+ return typeof siteForHost === 'function' ? siteForHost(host) : null;
+ }
+
+ /** At most eight; people first, then this person's agents, then places. */
+ async function get({ q = '', site = '', user = null } = {}) {
+ const query = text(q, Q_MAX).toLowerCase();
+ const signedIn = Boolean(user && (user.id != null || user.subject_id));
+ const current = siteOf(site);
+ if (!signedIn) {
+ const hit = held.get(query);
+ if (hit && now() - hit.at < MINUTE) return { body: hit.body, cached: true };
+ const people = await peopleRows(query, null);
+ const body = { mentions: people.slice(0, MAX) };
+ if (held.size >= 500) held.clear();
+ held.set(query, { at: now(), body });
+ return { body, cached: false };
+ }
+
+ const out = [];
+ const room = () => MAX - out.length;
+ for (const row of await peopleRows(query, user)) { if (out.length >= MAX) break; out.push(row); }
+ if (room() > 0) for (const row of await agentRows(query, user)) { if (out.length >= MAX) break; out.push(row); }
+ if (room() > 0) {
+ for (const s of sites) {
+ if (out.length >= MAX) break;
+ if (!s || s.status !== 'open' || (current && s.id === current.id)) continue;
+ if (query && !`${s.id} ${s.name} ${s.host} ${s.tagline || ''}`.toLowerCase().includes(query)) continue;
+ const row = siteRow(s, { fullName });
+ if (row) out.push(row);
+ }
+ }
+ if (room() > 0) {
+ for (const row of await liveChannels()) {
+ if (out.length >= MAX) break;
+ if (query && !`${row.id} ${row.label}`.toLowerCase().includes(query)) continue;
+ out.push(row);
+ }
+ }
+ return { body: { mentions: out.slice(0, MAX) }, cached: false };
+ }
+
+ async function peopleRows(query, user) {
+ if (!db) return [];
+ const signedIn = Boolean(user && (user.id != null || user.subject_id));
+ const params = [];
+ let sql = 'SELECT u.id, u.subject_id, u.username, u.display_name, u.avatar_url';
+ if (signedIn) {
+ sql += ', EXISTS (SELECT 1 FROM follows f WHERE f.follower_id = ? AND f.followed_id = u.id) AS followed';
+ params.push(user.id);
+ }
+ sql += ` FROM users u WHERE u.is_anon = 0 AND COALESCE(u.is_banned, 0) = 0 AND u.merged_into IS NULL
+ AND u.deleted_at IS NULL AND u.subject_id IS NOT NULL`;
+ if (signedIn) { sql += ' AND u.id <> ?'; params.push(user.id); }
+ else sql += ' AND u.profile_public = 1';
+ if (query) {
+ sql += " AND (position(lower(?) in lower(u.username)) > 0 OR position(lower(?) in lower(coalesce(u.display_name, ''))) > 0)";
+ params.push(query, query);
+ }
+ const order = [];
+ if (query) { order.push('(lower(u.username) = lower(?)) DESC'); params.push(query); }
+ if (signedIn) order.push('followed DESC');
+ order.push('u.username ASC');
+ sql += ` ORDER BY ${order.join(', ')} LIMIT ?`;
+ params.push(MAX);
+ try {
+ const rows = await db.prepare(sql).all(...params);
+ return rows.map(personRow).filter(Boolean).slice(0, MAX);
+ } catch (err) {
+ if (log && log.warn) log.warn(`[FrameMentions] people: ${String((err && err.message) || err).slice(0, 160)}`);
+ return [];
+ }
+ }
+
+ async function agentRows(query, user) {
+ if (!db || !user) return [];
+ let subject = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || '')) ? String(user.subject_id) : '';
+ if (!subject && user.id != null) {
+ try { const row = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id); subject = (row && row.subject_id) || ''; } catch { subject = ''; }
+ }
+ if (!subject) return [];
+ const params = [subject];
+ let sql = 'SELECT id, kind, name, service, machine_id, status FROM agent_directory WHERE owner_subject = ?';
+ if (query) { sql += ' AND position(lower(?) in lower(name)) > 0'; params.push(query); }
+ sql += " ORDER BY CASE status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, updated_at DESC, id ASC LIMIT ?";
+ params.push(MAX);
+ try {
+ const rows = await db.prepare(sql).all(...params);
+ return rows.map(agentRow).filter(Boolean).slice(0, MAX);
+ } catch (err) {
+ if (log && log.warn) log.warn(`[FrameMentions] agents: ${String((err && err.message) || err).slice(0, 160)}`);
+ return [];
+ }
+ }
+
+ /** The per-actor window: 120 a minute for one person, one hashed address when signed out. */
+ function allow(actor) {
+ const at = now();
+ const key = String(actor || '');
+ if (!key) return { ok: true };
+ let w = hits.get(key);
+ if (!w || at >= w.reset) { w = { n: 0, reset: at + MINUTE }; hits.set(key, w); }
+ if (w.n >= perMinute) return { ok: false, retryAfter: Math.max(1, Math.ceil((w.reset - at) / 1000)) };
+ w.n++;
+ if (hits.size > 20_000) for (const [k, v] of hits) if (at >= v.reset) hits.delete(k);
+ return { ok: true };
+ }
+
+ /** The address a signed-out request is counted by: a short hash in memory, never the address itself. */
+ function actorKey(req, user) {
+ if (user && (user.subject_id || user.id != null)) return `user:${user.subject_id || user.id}`;
+ const ip = String((req && req.ip) || '');
+ return `ip:${crypto.createHash('sha256').update(ip).digest('hex').slice(0, 20)}`;
+ }
+
+ return { get, allow, actorKey, siteOf, _state: () => ({ held: held.size, hits: hits.size }) };
+}
+
+module.exports = { createFrameMentions, avatarOf, personRow, agentRow, siteRow, channelRow, publicLive, MAX, MINUTE };
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
//
//
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }
// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }
+// GET /api/frame/commands?site=&q= the composer's / menu: site commands, network defaults, a person's own
+// GET /api/frame/mentions?q=&site= the composer's @ menu: public people, the person's agents, places (max 8)
// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed
// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed
// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; no cache)
// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; no cache)
// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted
// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted
@@ -32,6 +34,8 @@ const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MER
@@ -32,6 +34,8 @@ const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MER
const { createMenuCards } = require('./menu-cards');
const { createMenuCards } = require('./menu-cards');
const { createFrameChoices } = require('./choices');
const { createFrameChoices } = require('./choices');
const { createFrameAsk } = require('./ask');
const { createFrameAsk } = require('./ask');
+const { read: readCommands } = require('./commands');
+const { createFrameMentions } = require('./mentions');
const seen = require('./seen');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
@@ -165,7 +169,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
@@ -165,7 +169,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
};
}
}
-async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, today = null, now = Date.now } = {}) {
+async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, person = null, today = null, now = Date.now, sites = SITES, places = LISTED, mentionLimit = 120 } = {}) {
const services = (config && config.services) || {};
const services = (config && config.services) || {};
aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';
aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';
const choices = createFrameChoices({
const choices = createFrameChoices({
@@ -239,6 +243,23 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -239,6 +243,23 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); return r.ok ? await r.json() : null; } catch { return null; }
try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); return r.ok ? await r.json() : null; } catch { return null; }
}
}
+ // The composer's Reach (composer-post-ask-do.md, R3): the site's slash commands and the @ menu. Both
+ // read a person's session when one is sent (Bearer or the Network cookie) and answer as a signed-out
+ // visitor otherwise; neither keeps a person's own answer.
+ const sessionAuth = require('../auth/session');
+ async function defaultPerson(req) {
+ const token = sessionAuth.sessionToken(req);
+ if (!token) return null;
+ const publicKey = req.app && req.app.locals && req.app.locals.publicKey;
+ if (!publicKey || !config || !config.jwt || !config.jwt.issuer) return null;
+ try { const out = await sessionAuth.verifySession(token, { db, publicKey, config }); return out && !out.error ? out.user : null; } catch { return null; }
+ }
+ const personOf = typeof person === 'function' ? person : defaultPerson;
+ const mentions = createFrameMentions({
+ db, sites: places, siteForHost, fullName, now, perMinute: mentionLimit,
+ liveUrl: svcUrl('live', 'http://127.0.0.1:3000'),
+ });
+
async function refreshRank() {
async function refreshRank() {
const scores = Object.assign({}, rank.scores);
const scores = Object.assign({}, rank.scores);
// Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only).
// Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only).
@@ -1018,6 +1039,52 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -1018,6 +1039,52 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(JSON.stringify(got.body));
res.send(JSON.stringify(got.body));
});
});
+ // GET /api/frame/commands?site=<host>&q= — the composer's slash commands (composer-post-ask-do.md, R3):
+ // every site's /ask, /do, /remind and /timer, the site's own from its manifest, and a signed-in person's
+ // saved snippets. Site commands come first, a name appears once, and q filters by name or hint.
+ // Signed out the answer is public and held 60 s; signed in it is the person's own and never kept.
+ const commandHeld = new Map();
+ router.get('/commands', async (req, res) => {
+ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
+ const site = String(req.query.site || '').slice(0, 255);
+ const q = String(req.query.q || '').slice(0, 50);
+ const user = await personOf(req).catch(() => null);
+ let prefs = null;
+ if (user && user.id != null) {
+ try { const row = await db.prepare('SELECT composer_prefs FROM user_preferences WHERE user_id = ?').get(user.id); prefs = row ? row.composer_prefs : null; } catch { prefs = null; }
+ }
+ if (user) {
+ res.set('Cache-Control', 'private, no-store');
+ } else {
+ const key = `${site.toLowerCase()}|${q.toLowerCase()}`;
+ const kept = commandHeld.get(key);
+ res.set('Cache-Control', 'public, max-age=60');
+ if (kept && now() - kept.at < 60_000) return res.send(kept.body);
+ const body = JSON.stringify(readCommands({ site, q, prefs: null, sites, siteForHost }));
+ if (commandHeld.size >= 500) commandHeld.clear();
+ commandHeld.set(key, { at: now(), body });
+ return res.send(body);
+ }
+ res.send(JSON.stringify(readCommands({ site, q, signedIn: true, prefs, sites, siteForHost })));
+ });
+
+ // GET /api/frame/mentions?q=&site= — the composer's @ menu (composer-post-ask-do.md, R3): public people
+ // first, then the person's own agents from the agent directory, then OpenVibe's places (open sites and
+ // Live's public channels). At most eight; signed out it is public people only. Rate-limited per actor in
+ // memory (a signed-in subject, or a hash of the address), never an email or a private account field.
+ router.get('/mentions', async (req, res) => {
+ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
+ const user = await personOf(req).catch(() => null);
+ const allowed = mentions.allow(mentions.actorKey(req, user));
+ if (!allowed.ok) {
+ res.set({ 'Cache-Control': 'private, no-store', 'Retry-After': String(allowed.retryAfter) });
+ return res.status(429).json({ error: 'Too many mention searches: try again in a minute', code: 'frame.mentions_rate_limited' });
+ }
+ const got = await mentions.get({ q: String(req.query.q || '').slice(0, 50), site: String(req.query.site || '').slice(0, 255), user });
+ res.set('Cache-Control', user ? 'private, no-store' : 'public, max-age=60');
+ res.send(JSON.stringify(got.body));
+ });
+
// POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the
// POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the
// chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at
// chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at
// /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.
// /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.
@@ -22,6 +22,9 @@ const RAW_SITES = contracts.services.manifests.filter((m) => m.site && !HIDDEN_S
@@ -22,6 +22,9 @@ const RAW_SITES = contracts.services.manifests.filter((m) => m.site && !HIDDEN_S
// A site's own footer-strip items (label, icon, link, and a number or a line), at most three. They are
// A site's own footer-strip items (label, icon, link, and a number or a line), at most three. They are
// validated again at the read (server/frame/service.js stripOf): text only, an https link on an OpenVibe domain.
// validated again at the read (server/frame/service.js stripOf): text only, an https link on an OpenVibe domain.
...(Array.isArray(m.site.strip) ? { strip: m.site.strip } : {}),
...(Array.isArray(m.site.strip) ? { strip: m.site.strip } : {}),
+ // The site's own composer commands (composer-post-ask-do.md, R3), validated again at the read
+ // (server/frame/commands.js siteRows): a slash word, a one-line hint and what a pick inserts.
+ ...(Array.isArray(m.site.commands) ? { commands: m.site.commands } : {}),
...(m.site.host ? { host: m.site.host } : {}),
...(m.site.host ? { host: m.site.host } : {}),
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
function manifestHost(service) {
function manifestHost(service) {
@@ -570,7 +570,7 @@ const ACCOUNT_ID_LINK = {
@@ -570,7 +570,7 @@ const ACCOUNT_ID_LINK = {
const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a
// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a
// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.
// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.
-const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes).';
+const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes). The composer\'s `/` menu is `GET /api/frame/commands?site=<host>&q=` (the site\'s own, the network\'s /ask, /do, /remind and /timer, and a signed-in person\'s saved commands) and its `@` menu is `GET /api/frame/mentions?q=&site=` (public people first, then the person\'s agents and OpenVibe places, at most eight).';
function llmsTxt() {
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
@@ -590,7 +590,7 @@ function llmsTxt() {
@@ -590,7 +590,7 @@ function llmsTxt() {
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
- { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages) and POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events' },
+ { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages), POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events, GET /api/frame/commands for the / menu and GET /api/frame/mentions for the @ menu' },
{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// GET /api/frame/commands: every site's /ask, /do, /remind and /timer, a site's own from its Frame manifest, and a
+// signed-in person's saved composer snippets. Site commands first, a name once, q filtering, and the cache rule
+// (signed out public for 60 s, signed in private and never kept).
+const assert = require('assert');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+const { read: readCommands } = require('../server/frame/commands');
+
+(async () => {
+ const db = getDb();
+ const subject = ids.newId('user');
+ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, profile_public)
+ VALUES ('sam', 'Sam Rivera', '[email protected]', 'x', ?, 1) RETURNING id`).get(subject);
+ const me = { id: Number(row.id), subject_id: subject, username: 'sam' };
+ await db.prepare(`INSERT INTO user_preferences (user_id, composer_prefs, composer_rev) VALUES (?, ?, 2)`)
+ .run(me.id, JSON.stringify({ snippets: [
+ { trigger: '/sig', label: 'Signature', text: '— Sam' },
+ { trigger: '/standup', label: 'Stand-up notes', text: 'Yesterday: ' },
+ { trigger: 'not a slash', label: 'bad', text: 'x' },
+ ] }));
+
+ const sites = [{
+ id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes',
+ commands: [
+ { name: 'poll', summary: 'Ask the room a question', insert: '/poll ', needs_sign_in: true },
+ { name: 'ask', hint: 'Ask the community', insert: '/ask ' },
+ { name: 'Bad Name', hint: 'not a slash word', insert: '/bad ' },
+ ],
+ }];
+
+ let clock = 1_000_000;
+ let who = null;
+ const svc = await createFrameService(db, { services: {} }, null, {
+ person: async () => who, sites, places: sites, now: () => clock, mentionLimit: 120,
+ });
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/frame/commands`;
+ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
+
+ try {
+ // Signed out: the site's own first (it replaces /ask), then the network's four; no saved commands.
+ let { r, d } = await get('?site=openvibe.community');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.match(r.headers.get('vary') || '', /Authorization/);
+ assert.deepStrictEqual(d.site, { id: 'community', name: 'Community', host: 'openvibe.community' });
+ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'do', 'remind', 'timer'],
+ 'the site first, its /ask replaces the network one, then the network four');
+ assert.deepStrictEqual(Object.keys(d.commands[0]).sort(), ['hint', 'insert', 'kind', 'name', 'needs_sign_in']);
+ assert.deepStrictEqual([d.commands[0].kind, d.commands[0].needs_sign_in, d.commands[0].hint], ['site', true, 'Ask the room a question']);
+ assert.deepStrictEqual([d.commands[1].kind, d.commands[1].hint], ['site', 'Ask the community']);
+ assert.deepStrictEqual(d.commands.slice(2).map((c) => c.kind), ['network', 'network', 'network']);
+ assert.strictEqual(d.commands.find((c) => c.name === 'do').insert, '/do ');
+ assert.ok(!d.commands.some((c) => c.name === 'sig'), 'saved commands need a person');
+
+ // The same signed-out answer is held 60 s, then the site manifest is read again.
+ sites[0].commands.push({ name: 'clip', hint: 'Make a clip' });
+ ({ d } = await get('?site=openvibe.community'));
+ assert.ok(!d.commands.some((c) => c.name === 'clip'), 'the signed-out answer is held 60 s');
+ clock += 61_000;
+ ({ d } = await get('?site=openvibe.community'));
+ assert.ok(d.commands.some((c) => c.name === 'clip'), 'after the minute the manifest is read again');
+
+ // q filters by the name and the hint; an unknown site still offers the network four.
+ ({ d } = await get('?site=community&q=poll'));
+ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll']);
+ ({ d } = await get('?site=community&q=SIGN'));
+ assert.deepStrictEqual(d.commands, [], 'a query that matches no name or hint is an empty list');
+ ({ d } = await get('?site=nope.example'));
+ assert.strictEqual(d.site, null);
+ assert.deepStrictEqual(d.commands.map((c) => c.name), ['ask', 'do', 'remind', 'timer']);
+
+ // Signed in: the person's own snippets come last, and nothing is kept.
+ who = me;
+ ({ r, d } = await get('?site=openvibe.community'));
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'clip', 'do', 'remind', 'timer', 'sig', 'standup']);
+ const mine = d.commands.find((c) => c.name === 'sig');
+ assert.deepStrictEqual(mine, { name: 'sig', hint: 'Signature', insert: '— Sam', kind: 'mine', needs_sign_in: false });
+ assert.ok(!d.commands.some((c) => c.name === 'not a slash'), 'an invalid snippet is not a command');
+ ({ d } = await get('?site=community&q=standup'));
+ assert.deepStrictEqual(d.commands.map((c) => c.name), ['standup']);
+
+ // The pure read is the same rule: an invalid manifest command is dropped.
+ const pure = readCommands({ site: 'community', sites, siteForHost: () => null });
+ assert.ok(!pure.commands.some((c) => c.name === 'bad name'));
+ assert.strictEqual(pure.commands[0].name, 'poll');
+
+ console.log(' ok frame commands: site, network and saved rows; signed-out cache; q\n');
+ } finally {
+ await new Promise((r2) => server.close(r2));
+ svc.stop();
+ }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+// GET /api/frame/mentions: public people first (avatar only from openvibe.media), then the person's own agents from
+// the agent directory, then open sites and Live's public channels. At most eight; signed out it is public people only.
+// The per-actor limit, the signed-out 60 s cache and the absence of any private account field are checked too.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+(async () => {
+ const db = getDb();
+ const addUser = async (username, extra = {}) => {
+ const subject = ids.newId('user');
+ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, is_anon, is_banned, profile_public, avatar_url)
+ VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?) RETURNING id`)
+ .get(username, extra.display || username, extra.email || null, subject, extra.anon ? 1 : 0, extra.banned ? 1 : 0,
+ extra.public === false ? 0 : 1, extra.avatar || null);
+ return { id: Number(row.id), subject_id: subject, username, display_name: extra.display || username };
+ };
+ const me = await addUser('sam', { display: 'Sam Rivera', email: '[email protected]' });
+ const ana = await addUser('ana', { display: 'Ana Followed', email: '[email protected]', avatar: 'https://openvibe.media/p/ana/screenshot' });
+ const anabel = await addUser('anabel', { display: 'Anabel Also', email: '[email protected]' });
+ await addUser('zoe', { display: 'Zoe Public', email: '[email protected]', avatar: 'https://openvibe.network/avatar/zoe?s=64' });
+ await addUser('hidden', { display: 'Hidden Person', public: false });
+ await addUser('banned', { display: 'Banned Person', banned: true });
+ await addUser('anon', { display: 'Anon Person', anon: true });
+ const other = await addUser('other', { display: 'Other Person' });
+ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, ana.id);
+ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, anabel.id);
+
+ const agent = async (owner, name, kind, status) => {
+ const id = ids.newId('agent');
+ await db.prepare(`INSERT INTO agent_directory (id, owner_subject, kind, name, service, skills, status)
+ VALUES (?, ?, ?, ?, ?, '[]', ?)`).run(id, owner.subject_id, kind, name, kind, status);
+ return id;
+ };
+ const actor = await agent(me, 'Actor', 'actor', 'online');
+ await agent(me, 'Codes', 'codes', 'offline');
+ await agent(other, 'Other Agent', 'actor', 'online');
+
+ const live = http.createServer((req, res) => {
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ streams: [
+ { username: 'streamer', display_name: 'Streamer One', avatar_url: 'https://openvibe.media/p/streamer/screenshot', is_public: true },
+ { username: 'secret', display_name: 'Secret Channel', visibility: 'private' },
+ { username: 'unlisted', display_name: 'Unlisted Channel', unlisted: true },
+ { username: 'bad name', display_name: 'Bad Channel', is_public: true },
+ ] }));
+ });
+ await new Promise((r) => live.listen(0, '127.0.0.1', r));
+
+ const places = [
+ { id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes' },
+ { id: 'live', name: 'Live', host: 'openvibe.live', status: 'open', tagline: 'Go live, watch streams' },
+ { id: 'network', name: 'Network', host: 'openvibe.network', status: 'open', tagline: 'Your account' },
+ { id: 'soon', name: 'Soon', host: 'openvibe.soon', status: 'soon' },
+ ];
+
+ let clock = 1_000_000;
+ let who = null;
+ const svc = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {
+ person: async () => who, places, sites: places, now: () => clock, mentionLimit: 50,
+ });
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/frame/mentions`;
+ const get = async (qs) => { const r = await fetch(`${base}${qs}`); const text = await r.text(); return { r, text, d: JSON.parse(text) }; };
+
+ try {
+ // Signed out: public people only, in the mention row shape; the 60 s public cache.
+ let { r, text, d } = await get('?q=ana');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.match(r.headers.get('vary') || '', /Authorization/);
+ assert.deepStrictEqual(d.mentions.map((m) => m.handle), ['ana', 'anabel'], 'an exact username first, then the rest');
+ assert.deepStrictEqual(d.mentions[0], {
+ kind: 'person', id: ana.subject_id, label: 'Ana Followed', handle: 'ana',
+ avatar: 'https://openvibe.media/p/ana/screenshot', url: 'https://openvibe.network/@ana',
+ });
+ assert.ok(d.mentions.every((m) => m.kind === 'person'), 'signed out has no agents or places');
+
+ ({ d } = await get('?q=zoe'));
+ assert.strictEqual(d.mentions.length, 1);
+ assert.ok(!('avatar' in d.mentions[0]), 'a non-Media avatar is never returned');
+ assert.ok(!/zoe@example\.test|sam@example\.test/.test(text), 'no email');
+
+ for (const q of ['hidden', 'banned', 'anon']) {
+ ({ d } = await get(`?q=${q}`));
+ assert.deepStrictEqual(d.mentions, [], `${q} is not a public person to mention`);
+ }
+
+ // Signed in: people first, then the person's agents (never another owner's), then places and channels.
+ who = me;
+ ({ r, d } = await get('?q=actor'));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['agent', actor]]);
+ assert.strictEqual(d.mentions[0].label, 'Actor');
+ assert.match(d.mentions[0].sub, /actor.*online/i);
+ assert.ok(!text.includes(other.subject_id) && !text.includes('Other Agent'), "another owner's agent is not listed");
+
+ ({ d } = await get('?q=live'));
+ assert.ok(d.mentions.some((m) => m.kind === 'site' && m.id === 'live' && m.url === 'https://openvibe.live/'));
+ assert.ok(!d.mentions.some((m) => m.id === 'soon'), 'a site that is not open is not a place');
+
+ ({ d } = await get('?q=streamer'));
+ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['channel', 'streamer']]);
+ assert.strictEqual(d.mentions[0].url, 'https://openvibe.live/@streamer');
+ assert.strictEqual(d.mentions[0].avatar, 'https://openvibe.media/p/streamer/screenshot');
+ ({ d } = await get('?q=secret'));
+ assert.deepStrictEqual(d.mentions, [], 'a private Live channel is never mentioned');
+ ({ d } = await get('?q=unlisted'));
+ assert.deepStrictEqual(d.mentions, [], 'an unlisted Live channel is never mentioned');
+
+ ({ d } = await get('?site=openvibe.community&q=community'));
+ assert.ok(!d.mentions.some((m) => m.id === 'community'), 'the site the box is on is not suggested back');
+
+ ({ d } = await get('?q='));
+ assert.ok(d.mentions.length <= 8, 'at most eight rows');
+ const rank = { person: 0, agent: 1, site: 2, channel: 3 };
+ const ranks = d.mentions.map((m) => rank[m.kind]);
+ assert.deepStrictEqual(ranks, [...ranks].sort((a, b) => a - b), 'people first, then agents, then places');
+ assert.ok(!/@example\.test/.test(JSON.stringify(d)), 'no email in the signed-in answer');
+ assert.ok(!/password|owner_subject|machine_id|endpoint|skills/.test(JSON.stringify(d)), 'no private agent field');
+
+ // The per-actor limit is in memory and resets on the next minute; Retry-After says when.
+ const limited = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {
+ person: async () => me, places, sites: places, now: () => clock, mentionLimit: 2,
+ });
+ const app2 = express();
+ app2.set('trust proxy', 'loopback');
+ app2.use('/api/frame', limited.router);
+ const server2 = app2.listen(0, '127.0.0.1');
+ await new Promise((r2) => server2.once('listening', r2));
+ const base2 = `http://127.0.0.1:${server2.address().port}/api/frame/mentions`;
+ try {
+ assert.strictEqual((await fetch(`${base2}?q=a`)).status, 200);
+ assert.strictEqual((await fetch(`${base2}?q=b`)).status, 200);
+ const third = await fetch(`${base2}?q=c`);
+ assert.strictEqual(third.status, 429);
+ assert.ok(Number(third.headers.get('retry-after')) >= 1);
+ assert.strictEqual((await third.json()).code, 'frame.mentions_rate_limited');
+ clock += 61_000;
+ assert.strictEqual((await fetch(`${base2}?q=d`)).status, 200, 'the window is a minute');
+ } finally {
+ await new Promise((r2) => server2.close(r2));
+ limited.stop();
+ }
+
+ console.log(' ok frame mentions: people, agents, places, ranking, signed-out cache and the per-actor limit\n');
+ } finally {
+ await new Promise((r2) => server.close(r2));
+ live.close();
+ svc.stop();
+ }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -133,6 +133,7 @@ const REVIEWED = {
@@ -133,6 +133,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
+ 'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],
'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],