openvibers/openvibe.sdk Commits

openvibe-sdk 0.154.0: the Frame client (choices, query, providers, ask, events) (#234)

OP OpenVibers committed 1 days ago · 59080954af · 17 files, +432 −38

* openvibe-sdk/frame: the composer's model sheet, search and Ask

createFrameClient(client) gives choices(task), query(q, { scopes }), providers(), ask(body) and events(run):
the same calls every site's composer makes through OpenVibe.Network. choices answers the Model sheet
(OpenVibe's models enriched from Exchange, the person's keys, computers and plans); ask starts a question
and events reuses agents.events for its AG-UI stream, whose ANSWER event carries the numbered citations.
Exported from the frame subpath and the root, browser and ESM entries with types in types/frame.d.ts; the
shared navbar, footer and online() are unchanged.

Tests: the SDK frame test covers the calls, the token, query scopes and the run stream; the full suite
passes (129/135, six integration skips).

* Merge main into the frame client

* openvibe-sdk 0.154.0: the Frame client (choices, query, providers, ask, events)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex da13c27..afacdd7 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,14 @@
# Changelog
++## 0.154.0 — 2026-11-12
++
++- Add the Frame's data client to `openvibe-sdk/frame` (and `sdk.frame` in the root, browser and ESM entries):
++ `createFrameClient(client)` gives `choices(task)`, `query(q, { scopes })`, `providers()`, `ask(body)` and
++ `events(run)`, the same calls every site's composer makes through OpenVibe.Network. `choices` answers the Model sheet
++ (OpenVibe's models enriched from Exchange, the person's keys, computers and plans); `ask` starts a question and
++ `events` reuses `agents.events` for its AG-UI stream, whose `ANSWER` event carries the numbered citations. Types are
++ in `types/frame.d.ts`. The shared navbar, footer and `online()` are unchanged.
++
## 0.153.0 — 2026-11-11
- Add `objects.rotate(id, { degrees })` to `openvibe-sdk/media` (the v2 object client): turn a stored JPEG, PNG or WebP
ddiff --git a/README.md b/README.md
iindex bde2b88..a958eb4 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.153.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.154.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.153.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.154.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.153.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.154.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex c6fe59a..e179697 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.153.0",
++ "version": "0.154.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser.js b/browser.js
iindex e66da33..192834f 100644
---- a/browser.js
++++ b/browser.js
@@@ -38,6 +38,7 @@ module.exports = {
credentials: require('./src/credentials'),
jobs: require('./src/jobs'),
agents: require('./src/agents'),
++ frame: require('./src/frame'),
tools: require('./src/tools'),
projects: require('./src/projects'),
geo: require('./src/geo'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex d2a30d0..ed9f139 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.153.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.154.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -45,13 +45,14 @@ module.exports = {
credentials: __load(49),
jobs: __load(50),
agents: __load(52),
-- tools: __load(53),
-- projects: __load(54),
-- geo: __load(55),
-- history: __load(56),
-- userCard: __load(57),
-- player: __load(58),
-- agentSheet: __load(59),
++ frame: __load(53),
++ tools: __load(54),
++ projects: __load(55),
++ geo: __load(56),
++ history: __load(57),
++ userCard: __load(58),
++ player: __load(59),
++ agentSheet: __load(60),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -66,7 +67,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.153.0';
++const SDK_VERSION = '0.154.0';
module.exports = {
createClient,
@@@ -6713,7 +6714,230 @@ function createAgentsClient(client, { baseUrl, service = 'network', audience = '
}
module.exports = { createAgentsClient };
},
--// ── 53: src/tools.js ────────────────────────────────────────────────
++// ── 53: src/frame.js ────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++const { createAgentsClient } = __load(52);
++
++/**
++ * openvibe-sdk/frame — the OpenVibe Frame: the navbar, footer and themes every OpenVibe site sits in, for any app: the universal navbar (one
++ * account menu, notifications, the site switcher, sign in and out), the shared footer (network
++ * links, legal links, the "shipped X ago" line and an Updates link), the "shipped" views and the
++ * theme loader. These are the same files every OpenVibe site runs, served by OpenVibe.Network from
++ * the published openvibe-shared release. No dependencies; nothing is bundled into your app.
++ *
++ * In the browser:
++ *
++ * // mountFrame from the openvibe-sdk/frame entry (CommonJS or ESM)
++ * const { navbar, footer } = await mountFrame({
++ * service: 'myapp', // your app's id (brand + analytics)
++ * brand: { name: 'My App' }, // optional brand override
++ * links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }],
++ * menu: { before: [{ label: 'My projects', href: '/projects', icon: 'fa-folder' }] },
++ * sessionUrl: '/auth/me', // your server session ({ user }), optional
++ * loginUrl: '/auth/login?next={path}', // {url} full return URL, {path} local path
++ * logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too
++ * footer: { links: [{ heading: 'My App', items: [{ name: 'About', url: '/about' }] }], updates: '/updates' },
++ * });
++ *
++ * Server-rendered apps put frameTags(opts) in <head>/<body> instead (the same markup this module
++ * would inject, as a string; the JSON config is escaped for a <script> element):
++ *
++ * // frameTags from the openvibe-sdk/frame entry
++ * const t = frameTags({ service: 'myapp', links: [...] });
++ * html = `<head>${t.head}</head><body>${t.bodyStart}…${t.bodyEnd}</body>`;
++ *
++ * Your CSP must allow `script-src https://openvibe.network` and `connect-src https://openvibe.network`
++ * (the navbar asks /api/auth/me and the notifications there; the shipped line reads
++ * /api/v1/changelog). Signed-in state comes from the shared ov_token, else from sessionUrl. For
++ * your own users to be recognised across the network, sign them in with OpenVibe (openvibe-sdk/auth).
++ * The Frame is progressive: if openvibe.network is unreachable your page still renders.
++ */
++
++const DEFAULT_BASE = 'https://openvibe.network';
++const FILES = { theme: 'theme-loader.js', navbar: 'navbar.js', footer: 'footer.js', shipped: 'shipped.js' };
++
++function baseOf(base) {
++ const b = String(base || DEFAULT_BASE).replace(/\/+$/, '');
++ const ok = /^https:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b) || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(b);
++ if (!ok) throw new TypeError(`openvibe-sdk/frame: base must be an https origin (or http://localhost for development), got ${base}`);
++ return b;
++}
++
++/** The script URLs on a base: the release OpenVibe.Network currently serves at /shared/. */
++function scriptUrls({ base } = {}) {
++ const b = baseOf(base);
++ const out = {};
++ for (const [k, f] of Object.entries(FILES)) out[k] = `${b}/shared/${f}`;
++ return out;
++}
++
++/** The navbar and footer configurations mountFrame passes to OpenVibeNavbar.init / OpenVibeFooter.init. */
++function frameConfig(opts = {}) {
++ const o = opts || {};
++ const b = baseOf(o.base);
++ const navbar = {
++ service: o.service || 'app',
++ apiBase: b,
++ ...(o.brand ? { brand: o.brand } : {}),
++ ...(Array.isArray(o.links) ? { links: o.links } : {}),
++ ...(o.menu ? { menu: o.menu } : {}),
++ ...(o.sessionUrl ? { sessionUrl: o.sessionUrl } : {}),
++ ...(o.loginUrl ? { loginUrl: o.loginUrl } : {}),
++ ...(o.logoutUrl ? { logoutUrl: o.logoutUrl } : {}),
++ ...(o.silentLogin ? { silentLogin: o.silentLogin } : {}),
++ ...(o.navbar && typeof o.navbar === 'object' ? o.navbar : {}),
++ };
++ const footer = o.footer === false ? null : {
++ service: o.service || 'app',
++ mount: '#ov-footer',
++ variant: 'full',
++ apiBase: b,
++ ...(o.brand && o.brand.name ? { brandName: o.brand.name } : {}),
++ ...(o.shipped === false ? { shipped: false } : {}),
++ ...(o.footer && typeof o.footer === 'object' ? o.footer : {}),
++ };
++ return { navbar, footer };
++}
++
++const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
++const attr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
++
++/** Markup for server-rendered pages: { head, bodyStart, bodyEnd } strings. */
++function frameTags(opts = {}) {
++ const urls = scriptUrls(opts);
++ const cfg = frameConfig(opts);
++ const head = [
++ opts.theme === false ? '' : `<script src="${attr(urls.theme)}" defer></script>`,
++ `<script src="${attr(urls.navbar)}" defer></script>`,
++ cfg.footer ? `<script src="${attr(urls.footer)}" defer></script>` : '',
++ ].filter(Boolean).join('\n');
++ const bodyStart = '<div id="navbar-mount"></div>';
++ const init = `document.addEventListener('DOMContentLoaded',function(){var c=${jsonForScript(cfg)};`
++ + `try{if(window.OpenVibeNavbar)OpenVibeNavbar.init(c.navbar);}catch(e){}`
++ + `try{if(c.footer&&window.OpenVibeFooter)OpenVibeFooter.init(c.footer);}catch(e){}});`;
++ const bodyEnd = `${cfg.footer ? '<footer id="ov-footer"></footer>\n' : ''}<script>${init}</script>`;
++ return { head, bodyStart, bodyEnd, config: cfg, urls };
++}
++
++function loadScript(doc, src) {
++ return new Promise((resolve) => {
++ const existing = doc.querySelector(`script[data-ov-frame="${src}"]`);
++ if (existing) { if (existing.dataset.loaded) resolve(true); else existing.addEventListener('load', () => resolve(true)); return; }
++ const s = doc.createElement('script');
++ s.src = src;
++ s.async = true;
++ s.setAttribute('data-ov-frame', src);
++ s.addEventListener('load', () => { s.dataset.loaded = '1'; resolve(true); });
++ s.addEventListener('error', () => resolve(false));
++ (doc.head || doc.documentElement).appendChild(s);
++ });
++}
++
++/**
++ * Browser: load the OpenVibe Frame and mount it. Resolves { navbar, footer } (the initialised
++ * globals, or null for a part that could not load). Never throws for a network failure.
++ */
++async function mountFrame(opts = {}) {
++ const doc = (opts && opts.document) || (typeof document !== 'undefined' ? document : null);
++ const win = (opts && opts.window) || (typeof window !== 'undefined' ? window : null);
++ if (!doc || !win) throw new Error('openvibe-sdk/frame: mountFrame runs in a browser; use frameTags() on the server');
++ const urls = scriptUrls(opts);
++ const cfg = frameConfig(opts);
++ if (!doc.getElementById('navbar-mount')) {
++ const m = doc.createElement('div');
++ m.id = 'navbar-mount';
++ doc.body.insertBefore(m, doc.body.firstChild);
++ }
++ if (cfg.footer && !doc.querySelector(cfg.footer.mount)) {
++ const f = doc.createElement('footer');
++ f.id = String(cfg.footer.mount).replace(/^#/, '');
++ doc.body.appendChild(f);
++ }
++ const loads = [];
++ if (opts.theme !== false) loads.push(loadScript(doc, urls.theme));
++ const navOk = loadScript(doc, urls.navbar);
++ const footOk = cfg.footer ? loadScript(doc, urls.footer) : Promise.resolve(false);
++ await Promise.all([...loads, navOk, footOk]);
++ let navbar = null;
++ let footer = null;
++ try { if (win.OpenVibeNavbar) { win.OpenVibeNavbar.init(cfg.navbar); navbar = win.OpenVibeNavbar; } } catch { navbar = null; }
++ try { if (cfg.footer && win.OpenVibeFooter) { win.OpenVibeFooter.init(cfg.footer); footer = win.OpenVibeFooter; } } catch { footer = null; }
++ return { navbar, footer };
++}
++
++/**
++ * The network's "who is online" read, the data the shared footer strip draws: GET <base>/api/frame/online
++ * (openvibe.network). `site` is the calling site's id (its Frame service id), so the answer carries that
++ * site's own strip items; omit it and `items` is empty. The answer is returned as it is:
++ *
++ * { members, guests, total, people: [{ name, username, avatar, site }], more,
++ * live: { channels, watching, first: [{ name, url }] }, counts: [{ label, value, icon, url }],
++ * items: [{ label, icon, link, value? , line? }], sites, views_7d }
++ *
++ * A guest is a number only: the read never says who they are, where they are or what made them distinct.
++ * Public and CORS-open, cached 30 seconds at the edge. Throws on a non-2xx answer or when fetch is missing.
++ */
++async function online({ base = DEFAULT_BASE, site = '', signal, fetch: fetchImpl = (typeof fetch === 'function' ? fetch : null) } = {}) {
++ const b = baseOf(base);
++ if (typeof fetchImpl !== 'function') throw new TypeError('openvibe-sdk/frame: online needs a fetch implementation');
++ const q = /^[a-z0-9][a-z0-9-]{0,39}$/.test(String(site || '')) ? `?site=${encodeURIComponent(String(site))}` : '';
++ const res = await fetchImpl(`${b}/api/frame/online${q}`, { headers: { accept: 'application/json' }, credentials: 'omit', signal });
++ if (!res || !res.ok) throw new Error(`openvibe-sdk/frame: online answered ${res ? res.status : 'nothing'}`);
++ return await res.json();
++}
++
++/**
++ * openvibe-sdk/frame's data client (composer-post-ask-do.md, R1): the same Frame the shared composer calls, so a
++ * script or another agent can list the models, search, follow a run and ask exactly as a box does.
++ *
++ * const frame = createFrameClient(client);
++ * const sheet = await frame.choices('chat'); // { task, recommended, choice, groups: [...] }
++ * const found = await frame.query('surf maps', { scopes: ['web', 'openvibe'] });
++ * const { providers } = await frame.providers(); // the web ladder's offers
++ * const { run } = await frame.ask({ text: 'What is it?', sources: ['web'], depth: 'balanced' });
++ * for await (const event of frame.events(run)) { … } // AG-UI; ANSWER carries { answer, citations }
++ *
++ * @param client an SDK client (createClient)
++ * @param options { baseUrl } or { networkBaseUrl } overrides the network for a local service
++ */
++function createFrameClient(client, { baseUrl, networkBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createFrameClient: an SDK client is required');
++ const base = baseUrl || networkBaseUrl;
++ const net = (opts) => client.json({ service: 'network', audience: 'openvibe.network', ...(base ? { baseUrl: base } : {}), ...opts });
++ const agents = createAgentsClient(client, { ...(base ? { baseUrl: base } : {}), service: 'network', audience: 'openvibe.network' });
++ const TASKS = new Set(['chat', 'agent', 'code', 'image', 'research']);
++ const QUERY_KEYS = ['freshness', 'depth', 'limit', 'cursor', 'owner', 'type', 'site'];
++
++ const choices = async (task = 'chat', { signal } = {}) => {
++ const t = String(task || 'chat');
++ if (!TASKS.has(t)) throw new TypeError(`frame.choices: task is ${[...TASKS].join(', ')}`);
++ return net({ path: '/api/frame/choices', query: { task: t }, signal });
++ };
++ const query = async (q, options = {}) => {
++ if (typeof q !== 'string') throw new TypeError('frame.query: q must be a string');
++ const scopes = options.scopes === undefined ? ['network'] : Array.isArray(options.scopes) ? options.scopes : String(options.scopes).split(',');
++ const cleanScopes = scopes.map((s) => String(s).trim()).filter(Boolean);
++ if (!cleanScopes.length) throw new TypeError('frame.query: at least one scope is required');
++ const qs = { q, scopes: cleanScopes.join(',') };
++ for (const key of QUERY_KEYS) if (options[key] !== undefined && options[key] !== null) qs[key] = options[key];
++ return net({ path: '/api/frame/query', query: qs, signal: options.signal });
++ };
++ const providers = async ({ signal } = {}) => net({ path: '/api/frame/query/providers', signal });
++ const ask = async (body, { signal } = {}) => {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('frame.ask: a body object is required');
++ if (typeof body.text !== 'string' || !body.text.trim()) throw new TypeError('frame.ask: text is required');
++ return net({ method: 'POST', path: '/api/frame/ask', json: body, signal });
++ };
++ const events = (run, options) => agents.events(run, options);
++ const run = (id, options) => agents.run(id, options);
++
++ return { choices, query, providers, ask, events, run };
++}
++
++module.exports = { mountFrame, frameTags, frameConfig, scriptUrls, online, createFrameClient, DEFAULT_BASE };
++},
++// ── 54: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -7010,7 +7234,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 54: src/projects.js ─────────────────────────────────────────────
++// ── 55: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -7126,7 +7350,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 55: src/geo.js ──────────────────────────────────────────────────
++// ── 56: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -7190,7 +7414,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
module.exports = { createGeoClient };
},
--// ── 56: src/history.js ──────────────────────────────────────────────
++// ── 57: src/history.js ──────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -7218,7 +7442,7 @@ function createHistoryClient(client, { baseUrl } = {}) {
module.exports = { createHistoryClient };
},
--// ── 57: src/user-card.js ────────────────────────────────────────────
++// ── 58: src/user-card.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/** One public Network aggregate. Hidden/unavailable fields stay absent; no credentials are needed. */
@@@ -7234,7 +7458,7 @@ function createUserCardClient(client, { baseUrl } = {}) {
}
module.exports = { createUserCardClient };
},
--// ── 58: src/player.js ───────────────────────────────────────────────
++// ── 59: src/player.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/** Browser facade for Shared's portable live player. */
@@@ -7250,7 +7474,7 @@ function on(listener) {
}
module.exports = { current, handoff, on };
},
--// ── 59: src/agent-sheet.js ──────────────────────────────────────────
++// ── 60: src/agent-sheet.js ──────────────────────────────────────────
function (module, exports) {
'use strict';
/** Owner-only agent sheets through Network's fixed cross-site relay. The caller supplies their own Bearer. */
@@@ -7301,7 +7525,7 @@ export const {
uiLayout, community, homeFeed, feed, bot, search,
bench, meme, vision, themes, elements, design,
redact, chat, ai, credentials, jobs, agents,
-- tools, projects, geo, history, userCard, player,
-- agentSheet,
++ frame, tools, projects, geo, history, userCard,
++ player, agentSheet,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 9a80bb2..b270b67 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -214,6 +214,17 @@
"createFoodClient"
]
},
++ "frame": {
++ "object": [
++ "DEFAULT_BASE",
++ "createFrameClient",
++ "frameConfig",
++ "frameTags",
++ "mountFrame",
++ "online",
++ "scriptUrls"
++ ]
++ },
"geo": {
"object": [
"createGeoClient"
@@@ -931,6 +942,7 @@
},
"openvibe-sdk/frame": {
"DEFAULT_BASE": "string",
++ "createFrameClient": "function",
"frameConfig": "function",
"frameTags": "function",
"mountFrame": "function",
ddiff --git a/esm/frame.mjs b/esm/frame.mjs
iindex 1a93c83..7c396a1 100644
---- a/esm/frame.mjs
++++ b/esm/frame.mjs
@@@ -2,6 +2,7 @@
import cjs from '../src/frame.js';
export const {
-- mountFrame, frameTags, frameConfig, scriptUrls, online, DEFAULT_BASE,
++ mountFrame, frameTags, frameConfig, scriptUrls, online, createFrameClient,
++ DEFAULT_BASE,
} = cjs;
export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 4ff6c95..4ec017c 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -10,13 +10,13 @@ export const {
composer, cam, mediaUpload, ui, uiLayout, community,
homeFeed, feed, bot, search, bench, meme,
vision, themes, elements, design, redact, chat,
-- chatApps, ai, credentials, jobs, agents, tools,
-- projects, resources, vip, quest, live, inventory,
-- tips, blog, reviews, wiki, coupons, deals,
-- shoes, trade, space, host, news, food,
-- actor, codes, run, video, pics, work,
-- zone, git, play, download, openre, geo,
-- history, usage, commerce, moneyReasons, telemetry, userCard,
-- player, agentSheet,
++ chatApps, ai, credentials, jobs, agents, frame,
++ tools, projects, resources, vip, quest, live,
++ inventory, tips, blog, reviews, wiki, coupons,
++ deals, shoes, trade, space, host, news,
++ food, actor, codes, run, video, pics,
++ work, zone, git, play, download, openre,
++ geo, history, usage, commerce, moneyReasons, telemetry,
++ userCard, player, agentSheet,
} = cjs;
export default cjs;
ddiff --git a/index.js b/index.js
iindex c9de97b..4969b8c 100644
---- a/index.js
++++ b/index.js
@@@ -44,6 +44,7 @@ module.exports = {
credentials: require('./src/credentials'),
jobs: require('./src/jobs'),
agents: require('./src/agents'),
++ frame: require('./src/frame'),
tools: require('./src/tools'),
projects: require('./src/projects'),
resources: require('./src/resources'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 6c9ee69..724ce0a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.153.0",
++ "version": "0.154.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.153.0",
++ "version": "0.154.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 8b1fd30..b94ef5d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.153.0",
++ "version": "0.154.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 74559e2..30a91b1 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.153.0';
++const SDK_VERSION = '0.154.0';
module.exports = {
createClient,
ddiff --git a/src/frame.js b/src/frame.js
iindex ec4654f..b6953fb 100644
---- a/src/frame.js
++++ b/src/frame.js
@@@ -1,4 +1,5 @@
'use strict';
++const { createAgentsClient } = require('./agents');
/**
* openvibe-sdk/frame — the OpenVibe Frame: the navbar, footer and themes every OpenVibe site sits in, for any app: the universal navbar (one
@@@ -168,4 +169,52 @@ async function online({ base = DEFAULT_BASE, site = '', signal, fetch: fetchImpl
return await res.json();
}
--module.exports = { mountFrame, frameTags, frameConfig, scriptUrls, online, DEFAULT_BASE };
++/**
++ * openvibe-sdk/frame's data client (composer-post-ask-do.md, R1): the same Frame the shared composer calls, so a
++ * script or another agent can list the models, search, follow a run and ask exactly as a box does.
++ *
++ * const frame = createFrameClient(client);
++ * const sheet = await frame.choices('chat'); // { task, recommended, choice, groups: [...] }
++ * const found = await frame.query('surf maps', { scopes: ['web', 'openvibe'] });
++ * const { providers } = await frame.providers(); // the web ladder's offers
++ * const { run } = await frame.ask({ text: 'What is it?', sources: ['web'], depth: 'balanced' });
++ * for await (const event of frame.events(run)) { … } // AG-UI; ANSWER carries { answer, citations }
++ *
++ * @param client an SDK client (createClient)
++ * @param options { baseUrl } or { networkBaseUrl } overrides the network for a local service
++ */
++function createFrameClient(client, { baseUrl, networkBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createFrameClient: an SDK client is required');
++ const base = baseUrl || networkBaseUrl;
++ const net = (opts) => client.json({ service: 'network', audience: 'openvibe.network', ...(base ? { baseUrl: base } : {}), ...opts });
++ const agents = createAgentsClient(client, { ...(base ? { baseUrl: base } : {}), service: 'network', audience: 'openvibe.network' });
++ const TASKS = new Set(['chat', 'agent', 'code', 'image', 'research']);
++ const QUERY_KEYS = ['freshness', 'depth', 'limit', 'cursor', 'owner', 'type', 'site'];
++
++ const choices = async (task = 'chat', { signal } = {}) => {
++ const t = String(task || 'chat');
++ if (!TASKS.has(t)) throw new TypeError(`frame.choices: task is ${[...TASKS].join(', ')}`);
++ return net({ path: '/api/frame/choices', query: { task: t }, signal });
++ };
++ const query = async (q, options = {}) => {
++ if (typeof q !== 'string') throw new TypeError('frame.query: q must be a string');
++ const scopes = options.scopes === undefined ? ['network'] : Array.isArray(options.scopes) ? options.scopes : String(options.scopes).split(',');
++ const cleanScopes = scopes.map((s) => String(s).trim()).filter(Boolean);
++ if (!cleanScopes.length) throw new TypeError('frame.query: at least one scope is required');
++ const qs = { q, scopes: cleanScopes.join(',') };
++ for (const key of QUERY_KEYS) if (options[key] !== undefined && options[key] !== null) qs[key] = options[key];
++ return net({ path: '/api/frame/query', query: qs, signal: options.signal });
++ };
++ const providers = async ({ signal } = {}) => net({ path: '/api/frame/query/providers', signal });
++ const ask = async (body, { signal } = {}) => {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TypeError('frame.ask: a body object is required');
++ if (typeof body.text !== 'string' || !body.text.trim()) throw new TypeError('frame.ask: text is required');
++ return net({ method: 'POST', path: '/api/frame/ask', json: body, signal });
++ };
++ const events = (run, options) => agents.events(run, options);
++ const run = (id, options) => agents.run(id, options);
++
++ return { choices, query, providers, ask, events, run };
++}
++
++module.exports = { mountFrame, frameTags, frameConfig, scriptUrls, online, createFrameClient, DEFAULT_BASE };
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 4d5551b..50ddabb 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -2,7 +2,9 @@
// openvibe-sdk/frame: any app mounts the network's shared navbar, footer, shipped line and themes,
// with its own session endpoints; server-rendered apps get the same markup as strings.
const assert = require('node:assert/strict');
--const { mountFrame, frameTags, frameConfig, scriptUrls, online } = require('../src/frame');
++const { mountFrame, frameTags, frameConfig, scriptUrls, online, createFrameClient } = require('../src/frame');
++const { createClient } = require('../src/core');
++const { stubServer, send } = require('./helpers');
const opts = {
service: 'myapp',
@@@ -102,5 +104,46 @@ function fakeDom() {
await assert.rejects(() => online({ base: 'http://evil.example', fetch: fakeFetch }), /https origin/);
}
++ // The Frame's data client: the same calls the composer makes, with the person's token.
++ {
++ const sheet = { task: 'chat', recommended: { model: 'openai/gpt-5-mini', source: 'openvibe' }, choice: { model: 'openai/gpt-5-mini', source: 'openvibe' }, adult: false, groups: [{ id: 'openvibe', title: 'OpenVibe', rows: [{ model: 'openai/gpt-5-mini', source: 'openvibe', name: 'GPT-5 Mini', maker: 'openai', price: null, free: false }] }] };
++ const srv = await stubServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/frame/choices') return send(res, 200, sheet);
++ if (u.pathname === '/api/frame/query/providers') return send(res, 200, { providers: [{ provider: 'deepseek' }] });
++ if (u.pathname === '/api/frame/query') return send(res, 200, { results: [{ title: 'A', url: 'https://example.org/a' }], groups: { web: 1 }, citations: [], route: { cost_usd: 0 }, next_cursor: null });
++ if (u.pathname === '/api/frame/ask') return send(res, 202, { run: 'run_x' });
++ if (u.pathname === '/api/v1/runs/run_x/events') {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ return res.end('id: 1\nevent: RUN_FINISHED\ndata: {"type":"RUN_FINISHED"}\n\n');
++ }
++ return send(res, 404, {});
++ });
++ try {
++ const frame = createFrameClient(createClient({ baseUrls: { network: srv.url }, token: 'person-token' }));
++ assert.deepEqual(await frame.choices('chat'), sheet);
++ const found = await frame.query('surf maps', { scopes: ['web', 'openvibe'], depth: 'deep', limit: 5 });
++ assert.equal(found.results[0].title, 'A');
++ assert.deepEqual(await frame.providers(), { providers: [{ provider: 'deepseek' }] });
++ assert.deepEqual(await frame.ask({ text: 'What is it?', sources: ['web'], depth: 'balanced' }), { run: 'run_x' });
++ const events = [];
++ for await (const ev of frame.events('run_x')) events.push(ev.event);
++ assert.deepEqual(events, ['RUN_FINISHED']);
++ const hits = srv.requests.map((r) => ({ method: r.method, url: r.url, auth: r.headers.authorization, body: r.body.toString() }));
++ assert.equal(hits[0].url, '/api/frame/choices?task=chat');
++ assert.equal(hits[0].auth, 'Bearer person-token');
++ assert.match(hits[1].url, /^\/api\/frame\/query\?q=surf\+maps&scopes=web%2Copenvibe&depth=deep&limit=5$/);
++ assert.equal(hits[2].url, '/api/frame/query/providers');
++ assert.equal(hits[3].method, 'POST');
++ assert.equal(hits[3].url, '/api/frame/ask');
++ assert.match(hits[3].body, /"text":"What is it\?"/);
++ assert.throws(() => createFrameClient(null), /SDK client/);
++ const f = createFrameClient(createClient({ baseUrls: { network: srv.url } }));
++ await assert.rejects(() => f.choices('unreal'), /task is/);
++ await assert.rejects(() => f.query(1), /string/);
++ await assert.rejects(() => f.ask({}), /text is required/);
++ } finally { await srv.close(); }
++ }
++
console.log('frame: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 2215c41..9373f0e 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -29,6 +29,7 @@ import * as ai from './ai';
import * as credentials from './credentials';
import * as jobs from './jobs';
import * as agents from './agents';
++import * as frame from './frame';
import * as tools from './tools';
import * as projects from './projects';
import * as geo from './geo';
@@@ -68,6 +69,7 @@ export * as ai from './ai';
export * as credentials from './credentials';
export * as jobs from './jobs';
export * as agents from './agents';
++export * as frame from './frame';
export * as tools from './tools';
export * as projects from './projects';
export * as geo from './geo';
ddiff --git a/types/frame.d.ts b/types/frame.d.ts
iindex 1bd7812..938cc6b 100644
---- a/types/frame.d.ts
++++ b/types/frame.d.ts
@@@ -65,3 +65,54 @@ export function frameTags(opts?: FrameOptions): { head: string; bodyStart: strin
export function mountFrame(opts?: FrameOptions): Promise<{ navbar: unknown | null; footer: unknown | null }>;
/** The footer strip's read; `site` (the calling site's Frame id) brings that site's own items. */
export function online(opts?: { base?: string; site?: string; signal?: AbortSignal; fetch?: typeof fetch }): Promise<FrameOnline>;
++
++// ── the Frame's data client (composer-post-ask-do.md, R1) ────────────────────────────────────────────────────────
++import type { AgentEvent, AgentRun } from './agents';
++
++export interface FrameModelPrice { in: number | null; out: number | null; unit: 'USD per 1M tokens' }
++export interface FrameChoiceRow {
++ model: string;
++ source: 'openvibe' | 'keys' | 'machines' | 'plans';
++ machine?: string;
++ name: string;
++ maker: string | null;
++ price: FrameModelPrice | null;
++ free: boolean;
++ speed?: number;
++ score?: number;
++ url?: string;
++ /** true only for an 18+ model the person opted into; those are absent otherwise. */
++ adult?: boolean;
++}
++export interface FrameChoicesGroup { id: 'openvibe' | 'keys' | 'machines' | 'plans'; title: string; rows: FrameChoiceRow[]; note?: string }
++export interface FrameChoice { model: string; source: 'openvibe' | 'keys' | 'machines'; machine?: string }
++export interface FrameChoices {
++ task: 'chat' | 'agent' | 'code' | 'image' | 'research';
++ recommended: FrameChoice;
++ choice: FrameChoice;
++ adult: boolean;
++ groups: FrameChoicesGroup[];
++}
++/** GET /api/frame/query — Search's fabric answer, unchanged (search.fabric-result@1). */
++export interface FrameQueryResult { results: Array<Record<string, unknown>>; groups: Record<string, number>; citations: Array<Record<string, unknown>>; route: Record<string, unknown>; next_cursor: string | null; [key: string]: unknown }
++export interface FrameQueryOptions { scopes?: string[] | string; freshness?: string; depth?: string; limit?: number; cursor?: string; owner?: string; type?: string; site?: string; signal?: AbortSignal }
++export interface FrameAskBody {
++ text: string;
++ model?: string | FrameChoice;
++ refs?: Array<{ kind: string; id: string; title?: string; url?: string }>;
++ files?: Array<string | { id?: string; media_id?: string }>;
++ sources?: Array<'web' | 'openvibe' | 'mine' | 'page'>;
++ depth?: 'quick' | 'balanced' | 'deep';
++ context?: { site?: string; url?: string; title?: string; kind?: string; id?: string; text?: string };
++}
++export interface FrameRun { run: string }
++export interface FrameRunSnapshot { run: { id: string; status: 'running' | 'succeeded' | 'failed'; created_at: string; finished_at: string | null; answer: string | null; citations: Array<{ n: number; title: string; url: string; site: string | null }> | null; model: string | null; error: { code: string; message: string } | null } }
++export interface FrameClient {
++ choices(task?: 'chat' | 'agent' | 'code' | 'image' | 'research', options?: { signal?: AbortSignal }): Promise<FrameChoices>;
++ query(q: string, options?: FrameQueryOptions): Promise<FrameQueryResult>;
++ providers(options?: { signal?: AbortSignal }): Promise<{ providers: Array<Record<string, unknown>> }>;
++ ask(body: FrameAskBody, options?: { signal?: AbortSignal }): Promise<FrameRun>;
++ events(run: string, options?: { lastEventId?: string | number; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number }): AsyncIterable<AgentEvent>;
++ run(id: string, options?: { signal?: AbortSignal }): Promise<AgentRun | null>;
++}
++export function createFrameClient(client: unknown, options?: { baseUrl?: string; networkBaseUrl?: string }): FrameClient;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex dafb13f..b8d56dd 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -30,6 +30,7 @@ export * as ai from './ai';
export * as credentials from './credentials';
export * as jobs from './jobs';
export * as agents from './agents';
++export * as frame from './frame';
export * as tools from './tools';
export * as projects from './projects';
export * as resources from './resources';