openvibers/openvibe.sdk Commits

Prepare SDK 0.72.0 with typed Vision client (#99)

OP OpenVibers committed 15 days ago · e314d28d05 · 19 files, +673 −41

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1fb79de..c3da853 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.72.0 (unreleased)
++
++- **`openvibe-sdk/vision`** (VIS-5, plan §5.8b): typed browser and server calls for Vision's image tools, private search, face privacy and consent APIs, and MCP discovery. The service enforces token grants and consent; the client preserves problem errors and binary blur results.
++
## 0.71.0 (2026-10-28)
- **`openvibe-sdk/bench`** (BENCH-4, plan §4.12): browser-safe Bench client for kinds, tasks, pinned rounds, outside entries, judgements, boards, model summaries, streamed open JSONL datasets and private evaluations. `mcpTools()` lists the live Bench MCP tools through `POST /mcp`. Calls use the `bench` service origin and `openvibe.bench` audience; optional `actingSubject` sends `X-OV-Subject`. HTTP errors remain `OpenVibeError`.
ddiff --git a/README.md b/README.md
iindex b55913b..415f164 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.71.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.72.0 (prepared for release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients). 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.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
++After v0.72.0 is tagged:
++
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.71.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.72.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 Contracts v0.86.0, a devDependency the tests check them against).
@@@ -175,6 +177,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` |
| `openvibe-sdk/bench` | both | `createBenchClient(client, {actingSubject?})`: `kinds.list/get`, `tasks.create/get`, `rounds.create/get`, `entries.submit`, `judgements.next/cast`, `boards.get`, `models.get`, `datasets.get/iterate`, `evals.create/get/run/results`, `mcpTools()`. Audience `openvibe.bench`; service id `bench`. |
++| `openvibe-sdk/vision` | both | `createVisionClient(client)`: image questions, OCR, private search, network tools, UI checks, consent and face privacy, and `/mcp`. Audience `openvibe.vision`; service id `vision`. |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
ddiff --git a/STATUS.json b/STATUS.json
iindex 3b5e29c..ccd95ed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.71.0",
++ "version": "0.72.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",
@@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
-- "community, bot, search, bench, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients",
++ "community, bot, search, bench, vision, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
"no-looks (openvibe-sdk/no-looks): public AI text never describes a person, one filter and corpus for every service",
ddiff --git a/browser.js b/browser.js
iindex dc46a7c..02f4a50 100644
---- a/browser.js
++++ b/browser.js
@@@ -16,6 +16,7 @@ module.exports = {
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
++ vision: require('./src/vision'),
themes: require('./src/themes'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c9cad1c..9892d6f 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.71.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.72.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, bot, search, bench, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, bot, search, bench, vision, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -23,13 +23,14 @@ module.exports = {
bot: __load(15),
search: __load(16),
bench: __load(17),
-- themes: __load(18),
-- chat: __load(19),
-- ai: __load(20),
-- jobs: __load(21),
-- tools: __load(23),
-- projects: __load(24),
-- geo: __load(25),
++ vision: __load(18),
++ themes: __load(19),
++ chat: __load(20),
++ ai: __load(21),
++ jobs: __load(22),
++ tools: __load(24),
++ projects: __load(25),
++ geo: __load(26),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -44,7 +45,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.71.0';
++const SDK_VERSION = '0.72.0';
module.exports = {
createClient,
@@@ -2192,7 +2193,145 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 18: src/themes.js ───────────────────────────────────────────────
++// ── 18: src/vision.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/vision: OpenVibe.Vision's JSON API (plan 2026-10-28 §5.8b). Browser-safe: the calls take a Blob,
++ * File, ArrayBuffer or typed array, a data URL, or a public image URL, and every answer is the service's own JSON.
++ *
++ * const vision = createVisionClient(client);
++ * const { answer } = await vision.ask(png, { question: 'What is on the desk?' });
++ * const alt = await vision.alt(png); // { answer: { alt_text, editable, receipt } }
++ * await vision.uiCheck({ url: 'https://openvibe.vision' });
++ * const group = await vision.face.consents(); // the caller's own consent records
++ *
++ * Who may call what is decided by Vision on every request: an anonymous caller can use the local OCR reader, the
++ * UI check and the identity-free face tools; a private tool needs the person's own token; a service token needs the
++ * capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
++ * vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
++ * vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
++ * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.
++ *
++ * The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
++ * /.well-known/oauth-protected-resource, as RFC 9728 says.
++ */
++const DEFAULT_AUDIENCE = 'openvibe.vision';
++
++const IMAGE_KEYS = new Set(['image', 'image_url']);
++
++function isDataUrl(value) {
++ return typeof value === 'string' && value.startsWith('data:image/');
++}
++
++function isHttpUrl(value) {
++ return typeof value === 'string' && /^https?:\/\//.test(value);
++}
++
++function toBlob(image, contentType) {
++ if (typeof Blob !== 'undefined' && image instanceof Blob) return image;
++ if (typeof image === 'string' || image instanceof ArrayBuffer || ArrayBuffer.isView(image)) {
++ return new Blob([image], contentType ? { type: contentType } : undefined);
++ }
++ throw new TypeError('vision: pass a Blob/File, ArrayBuffer, typed array (Buffer), data URL or public image URL');
++}
++
++/**
++ * The image part of a body. A data URL or a public URL goes as JSON; bytes go as the multipart `image` field, which
++ * is what the browser forms and a service upload use. `extra` is merged into the same body.
++ */
++function bodyFor(image, extra = {}, { filename = 'image', contentType } = {}) {
++ if (isDataUrl(image)) return { json: { image, ...extra } };
++ if (isHttpUrl(image)) return { json: { image_url: image, ...extra } };
++ const form = new FormData();
++ for (const [k, v] of Object.entries(extra)) {
++ if (v === undefined || v === null) continue;
++ if (Array.isArray(v)) for (const x of v) form.append(k, String(x));
++ else form.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
++ }
++ form.append('image', toBlob(image, contentType), filename);
++ return { form };
++}
++
++function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createVisionClient: an openvibe-sdk core client is required');
++ const call = (opts) => client.json({ service: 'vision', baseUrl, audience, ...opts });
++
++ const one = (path, image, extra, opts) => call({ method: 'POST', path, ...bodyFor(image, extra, opts) });
++
++ const face = {
++ /** Identity-free count and boxes; no consent, no template. */
++ detect: (image, opts) => one('/api/v1/faces/detect', image, {}, opts),
++ /** The redacted PNG bytes and the blurred count from X-Faces-Blur. */
++ async blur(image, opts) {
++ const res = await client.request({ method: 'POST', path: '/api/v1/faces/blur', service: 'vision', baseUrl, audience, responseType: 'arrayBuffer', ...bodyFor(image, {}, opts) });
++ return { bytes: res.data, contentType: res.headers.get('content-type'), faceCount: Number(res.headers.get('x-faces-blurred')) };
++ },
++ texts: () => call({ path: '/api/v1/faces/consents/text' }),
++ consents: () => call({ path: '/api/v1/faces/consents' }),
++ grant: (feature, body) => call({ method: 'POST', path: `/api/v1/faces/consents/${encodeURIComponent(feature)}`, json: body }),
++ revoke: (feature, { channel } = {}) => call({ method: 'DELETE', path: `/api/v1/faces/consents/${encodeURIComponent(feature)}`, query: channel ? { channel } : undefined }),
++ /** Enrol with your own token; the matching consent must already be active. */
++ enrol: (feature, image, { channel } = {}, opts) => one(`/api/v1/faces/enrol/${encodeURIComponent(feature)}`, image, { channel }, opts),
++ verify: (image, opts) => one('/api/v1/faces/verify', image, {}, opts),
++ upload: (image, opts) => one('/api/v1/faces/media', image, {}, opts),
++ async media(id) {
++ const res = await client.request({ path: `/api/v1/faces/media/${encodeURIComponent(id)}`, service: 'vision', baseUrl, audience, responseType: 'arrayBuffer' });
++ return { bytes: res.data, contentType: res.headers.get('content-type') };
++ },
++ find: () => call({ method: 'POST', path: '/api/v1/faces/find', json: {} }),
++ tags: () => call({ path: '/api/v1/faces/cast/tags' }),
++ cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
++ groups: () => call({ path: '/api/v1/faces/library/groups' }),
++ correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
++ };
++
++ return {
++ /** Ask or describe an image: { answer } with the engine, model, provider, route, cost and latency. */
++ ask: (image, o = {}, opts) => one('/api/v1/ask', image, pick(o, ['question', 'task', 'mode', 'lang', 'model']), opts),
++ describe: (image, o = {}, opts) => one('/api/v1/describe', image, pick(o, ['question', 'task', 'mode', 'lang', 'model']), opts),
++ /** Read text locally first; `fallback` asks AI only when the local pass found nothing. */
++ ocr: (image, o = {}, opts) => one('/api/v1/ocr', image, pick(o, ['lang', 'fallback', 'model']), opts),
++ /** Local OCR against up to three AI hints. */
++ compare: (image, o = {}, opts) => one('/api/v1/compare', image, pick(o, ['task', 'question', 'models', 'lang']), opts),
++ /** One editable alt-text sentence about the scene; never about a person. */
++ alt: (image, opts) => one('/api/v1/alt', image, {}, opts),
++ /** Store one scoped CLIP embedding of your own item. */
++ index: (image, itemId, { resultUrl } = {}, opts) => one('/api/v1/index', image, { item_id: itemId, result_url: resultUrl }, opts),
++ /** Search only the caller's own private index, by words (`q`) or by an example image. */
++ find: (query, opts) => (typeof query === 'string' && !isDataUrl(query) && !isHttpUrl(query)
++ ? call({ path: '/api/v1/find', query: { q: query } })
++ : one('/api/v1/find', query, {}, opts)),
++ receipt: (image, o = {}, opts) => one('/api/v1/receipt', image, pick(o, ['fallback']), opts),
++ product: (image, o = {}, opts) => one('/api/v1/product', image, pick(o, ['allow_rerank']), opts),
++ watch: (image, classes, { previousImage, sourceRef } = {}, opts) => one('/api/v1/watch', image, { classes, previous_image: previousImage, source_ref: sourceRef }, opts),
++ moments: (frames, prompts) => call({ method: 'POST', path: '/api/v1/moments', json: { frames, prompts } }),
++ uiCheck: (source = {}, opts) => {
++ const { image, imageUrl, ...rest } = source;
++ if (image !== undefined) return one('/api/v1/ui-check', image, rest, opts);
++ if (imageUrl !== undefined) return call({ method: 'POST', path: '/api/v1/ui-check', json: { ...rest, image_url: imageUrl } });
++ return call({ method: 'POST', path: '/api/v1/ui-check', json: rest });
++ },
++ models: () => call({ path: '/api/v1/models' }),
++ benchmarks: ({ task, limit } = {}) => call({ path: '/api/v1/benchmarks', query: { task, limit } }),
++ answer: (id) => call({ path: `/api/v1/answers/${encodeURIComponent(id)}` }),
++ uiCheckById: (id) => call({ path: `/api/v1/ui-checks/${encodeURIComponent(id)}` }),
++ /** POST /mcp: a JSON-RPC 2.0 message or batch, answered as the protocol says. */
++ mcp: (message, { protocolVersion = '2025-06-18' } = {}) => call({ method: 'POST', path: '/mcp', json: message, headers: { 'MCP-Protocol-Version': protocolVersion } }),
++ protectedResource: () => call({ path: '/.well-known/oauth-protected-resource' }),
++ face,
++ };
++}
++
++function pick(o, keys) {
++ const out = {};
++ for (const k of keys) if (o && o[k] !== undefined) out[k] = o[k];
++ return out;
++}
++
++module.exports = { createVisionClient, DEFAULT_AUDIENCE };
++},
++// ── 19: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2342,7 +2481,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 19: src/chat.js ─────────────────────────────────────────────────
++// ── 20: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2584,7 +2723,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
--// ── 20: src/ai.js ───────────────────────────────────────────────────
++// ── 21: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2659,7 +2798,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
},
--// ── 21: src/jobs.js ─────────────────────────────────────────────────
++// ── 22: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2697,7 +2836,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(22);
++const { appendFiles } = __load(23);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -2861,7 +3000,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 22: src/core/form.js ────────────────────────────────────────────
++// ── 23: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2895,7 +3034,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 23: src/tools.js ────────────────────────────────────────────────
++// ── 24: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2929,8 +3068,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(22);
--const { createJobsClient } = __load(21);
++const { isUpload, appendFiles } = __load(23);
++const { createJobsClient } = __load(22);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -3180,7 +3319,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 24: src/projects.js ─────────────────────────────────────────────
++// ── 25: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3296,7 +3435,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 25: src/geo.js ──────────────────────────────────────────────────
++// ── 26: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3376,7 +3515,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
-- search, bench, themes, chat, ai, jobs,
-- tools, projects, geo,
++ search, bench, vision, themes, chat, ai,
++ jobs, tools, projects, geo,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 432140b..10e99cd 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -310,6 +310,12 @@
"createVipClient"
]
},
++ "vision": {
++ "object": [
++ "DEFAULT_AUDIENCE",
++ "createVisionClient"
++ ]
++ },
"wiki": {
"object": [
"createWikiClient"
@@@ -796,6 +802,10 @@
"createVipCache": "function",
"createVipClient": "function"
},
++ "openvibe-sdk/vision": {
++ "DEFAULT_AUDIENCE": "string",
++ "createVisionClient": "function"
++ },
"openvibe-sdk/wiki": {
"createWikiClient": "function"
},
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex b54c4e5..3cfb5bd 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,12 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, bot, search, bench, themes, chat,
-- ai, jobs, tools, projects, resources, vip,
-- quest, live, inventory, tips, blog, reviews,
-- wiki, coupons, deals, trade, space, host,
-- news, food, actor, run, video, work,
-- zone, play, download, openre, geo, usage,
-- commerce, telemetry,
++ community, bot, search, bench, vision, themes,
++ chat, ai, jobs, tools, projects, resources,
++ vip, quest, live, inventory, tips, blog,
++ reviews, wiki, coupons, deals, trade, space,
++ host, news, food, actor, run, video,
++ work, zone, play, download, openre, geo,
++ usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/vision.mjs b/esm/vision.mjs
nnew file mode 100644
iindex 0000000..c708253
---- /dev/null
++++ b/esm/vision.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/vision.js. Do not edit.
++import cjs from '../src/vision.js';
++
++export const {
++ createVisionClient, DEFAULT_AUDIENCE,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex c606b2a..8102d3f 100644
---- a/index.js
++++ b/index.js
@@@ -19,6 +19,7 @@ module.exports = {
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
++ vision: require('./src/vision'),
themes: require('./src/themes'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ddiff --git a/package-lock.json b/package-lock.json
iindex c3cdd8f..3af7517 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.71.0",
++ "version": "0.72.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.71.0",
++ "version": "0.72.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 51abc00..31d5802 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.71.0",
++ "version": "0.72.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",
@@@ -377,6 +377,11 @@
"import": "./esm/no-looks.mjs",
"default": "./src/no-looks.js"
},
++ "./vision": {
++ "types": "./types/vision.d.ts",
++ "import": "./esm/vision.mjs",
++ "default": "./src/vision.js"
++ },
"./front-page": {
"types": "./types/front-page.d.ts",
"import": "./esm/front-page.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex b462445..7ea28b1 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -70,6 +70,7 @@ const ENTRIES = {
'host.mjs': '../src/host.js',
'news.mjs': '../src/news.js',
'no-looks.mjs': '../src/no-looks.js',
++ 'vision.mjs': '../src/vision.js',
'front-page.mjs': '../src/front-page.js',
'food.mjs': '../src/food.js',
'actor.mjs': '../src/actor.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 9340b14..92c2668 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.71.0';
++const SDK_VERSION = '0.72.0';
module.exports = {
createClient,
ddiff --git a/src/vision.js b/src/vision.js
nnew file mode 100644
iindex 0000000..87cd2d9
---- /dev/null
++++ b/src/vision.js
@@@ -0,0 +1,135 @@
++'use strict';
++/**
++ * openvibe-sdk/vision: OpenVibe.Vision's JSON API (plan 2026-10-28 §5.8b). Browser-safe: the calls take a Blob,
++ * File, ArrayBuffer or typed array, a data URL, or a public image URL, and every answer is the service's own JSON.
++ *
++ * const vision = createVisionClient(client);
++ * const { answer } = await vision.ask(png, { question: 'What is on the desk?' });
++ * const alt = await vision.alt(png); // { answer: { alt_text, editable, receipt } }
++ * await vision.uiCheck({ url: 'https://openvibe.vision' });
++ * const group = await vision.face.consents(); // the caller's own consent records
++ *
++ * Who may call what is decided by Vision on every request: an anonymous caller can use the local OCR reader, the
++ * UI check and the identity-free face tools; a private tool needs the person's own token; a service token needs the
++ * capability its route names (vision.image.ask, vision.text.read, vision.model.compare, vision.alt.write,
++ * vision.media.find, vision.receipt.read, vision.product.match, vision.camera.watch, vision.video.moments,
++ * vision.face.cast.match). Face matching is refused without an active consent record for that person, and the refusal
++ * is the same whether or not the person exists. Public AI text has passed noLooks: a look-naming answer is replaced.
++ *
++ * The MCP endpoint is POST /mcp (Streamable HTTP, protocol 2025-06-18); clients that need a token discover it from
++ * /.well-known/oauth-protected-resource, as RFC 9728 says.
++ */
++const DEFAULT_AUDIENCE = 'openvibe.vision';
++
++const IMAGE_KEYS = new Set(['image', 'image_url']);
++
++function isDataUrl(value) {
++ return typeof value === 'string' && value.startsWith('data:image/');
++}
++
++function isHttpUrl(value) {
++ return typeof value === 'string' && /^https?:\/\//.test(value);
++}
++
++function toBlob(image, contentType) {
++ if (typeof Blob !== 'undefined' && image instanceof Blob) return image;
++ if (typeof image === 'string' || image instanceof ArrayBuffer || ArrayBuffer.isView(image)) {
++ return new Blob([image], contentType ? { type: contentType } : undefined);
++ }
++ throw new TypeError('vision: pass a Blob/File, ArrayBuffer, typed array (Buffer), data URL or public image URL');
++}
++
++/**
++ * The image part of a body. A data URL or a public URL goes as JSON; bytes go as the multipart `image` field, which
++ * is what the browser forms and a service upload use. `extra` is merged into the same body.
++ */
++function bodyFor(image, extra = {}, { filename = 'image', contentType } = {}) {
++ if (isDataUrl(image)) return { json: { image, ...extra } };
++ if (isHttpUrl(image)) return { json: { image_url: image, ...extra } };
++ const form = new FormData();
++ for (const [k, v] of Object.entries(extra)) {
++ if (v === undefined || v === null) continue;
++ if (Array.isArray(v)) for (const x of v) form.append(k, String(x));
++ else form.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
++ }
++ form.append('image', toBlob(image, contentType), filename);
++ return { form };
++}
++
++function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createVisionClient: an openvibe-sdk core client is required');
++ const call = (opts) => client.json({ service: 'vision', baseUrl, audience, ...opts });
++
++ const one = (path, image, extra, opts) => call({ method: 'POST', path, ...bodyFor(image, extra, opts) });
++
++ const face = {
++ /** Identity-free count and boxes; no consent, no template. */
++ detect: (image, opts) => one('/api/v1/faces/detect', image, {}, opts),
++ /** The redacted PNG bytes and the blurred count from X-Faces-Blur. */
++ async blur(image, opts) {
++ const res = await client.request({ method: 'POST', path: '/api/v1/faces/blur', service: 'vision', baseUrl, audience, responseType: 'arrayBuffer', ...bodyFor(image, {}, opts) });
++ return { bytes: res.data, contentType: res.headers.get('content-type'), faceCount: Number(res.headers.get('x-faces-blurred')) };
++ },
++ texts: () => call({ path: '/api/v1/faces/consents/text' }),
++ consents: () => call({ path: '/api/v1/faces/consents' }),
++ grant: (feature, body) => call({ method: 'POST', path: `/api/v1/faces/consents/${encodeURIComponent(feature)}`, json: body }),
++ revoke: (feature, { channel } = {}) => call({ method: 'DELETE', path: `/api/v1/faces/consents/${encodeURIComponent(feature)}`, query: channel ? { channel } : undefined }),
++ /** Enrol with your own token; the matching consent must already be active. */
++ enrol: (feature, image, { channel } = {}, opts) => one(`/api/v1/faces/enrol/${encodeURIComponent(feature)}`, image, { channel }, opts),
++ verify: (image, opts) => one('/api/v1/faces/verify', image, {}, opts),
++ upload: (image, opts) => one('/api/v1/faces/media', image, {}, opts),
++ async media(id) {
++ const res = await client.request({ path: `/api/v1/faces/media/${encodeURIComponent(id)}`, service: 'vision', baseUrl, audience, responseType: 'arrayBuffer' });
++ return { bytes: res.data, contentType: res.headers.get('content-type') };
++ },
++ find: () => call({ method: 'POST', path: '/api/v1/faces/find', json: {} }),
++ tags: () => call({ path: '/api/v1/faces/cast/tags' }),
++ cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
++ groups: () => call({ path: '/api/v1/faces/library/groups' }),
++ correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
++ };
++
++ return {
++ /** Ask or describe an image: { answer } with the engine, model, provider, route, cost and latency. */
++ ask: (image, o = {}, opts) => one('/api/v1/ask', image, pick(o, ['question', 'task', 'mode', 'lang', 'model']), opts),
++ describe: (image, o = {}, opts) => one('/api/v1/describe', image, pick(o, ['question', 'task', 'mode', 'lang', 'model']), opts),
++ /** Read text locally first; `fallback` asks AI only when the local pass found nothing. */
++ ocr: (image, o = {}, opts) => one('/api/v1/ocr', image, pick(o, ['lang', 'fallback', 'model']), opts),
++ /** Local OCR against up to three AI hints. */
++ compare: (image, o = {}, opts) => one('/api/v1/compare', image, pick(o, ['task', 'question', 'models', 'lang']), opts),
++ /** One editable alt-text sentence about the scene; never about a person. */
++ alt: (image, opts) => one('/api/v1/alt', image, {}, opts),
++ /** Store one scoped CLIP embedding of your own item. */
++ index: (image, itemId, { resultUrl } = {}, opts) => one('/api/v1/index', image, { item_id: itemId, result_url: resultUrl }, opts),
++ /** Search only the caller's own private index, by words (`q`) or by an example image. */
++ find: (query, opts) => (typeof query === 'string' && !isDataUrl(query) && !isHttpUrl(query)
++ ? call({ path: '/api/v1/find', query: { q: query } })
++ : one('/api/v1/find', query, {}, opts)),
++ receipt: (image, o = {}, opts) => one('/api/v1/receipt', image, pick(o, ['fallback']), opts),
++ product: (image, o = {}, opts) => one('/api/v1/product', image, pick(o, ['allow_rerank']), opts),
++ watch: (image, classes, { previousImage, sourceRef } = {}, opts) => one('/api/v1/watch', image, { classes, previous_image: previousImage, source_ref: sourceRef }, opts),
++ moments: (frames, prompts) => call({ method: 'POST', path: '/api/v1/moments', json: { frames, prompts } }),
++ uiCheck: (source = {}, opts) => {
++ const { image, imageUrl, ...rest } = source;
++ if (image !== undefined) return one('/api/v1/ui-check', image, rest, opts);
++ if (imageUrl !== undefined) return call({ method: 'POST', path: '/api/v1/ui-check', json: { ...rest, image_url: imageUrl } });
++ return call({ method: 'POST', path: '/api/v1/ui-check', json: rest });
++ },
++ models: () => call({ path: '/api/v1/models' }),
++ benchmarks: ({ task, limit } = {}) => call({ path: '/api/v1/benchmarks', query: { task, limit } }),
++ answer: (id) => call({ path: `/api/v1/answers/${encodeURIComponent(id)}` }),
++ uiCheckById: (id) => call({ path: `/api/v1/ui-checks/${encodeURIComponent(id)}` }),
++ /** POST /mcp: a JSON-RPC 2.0 message or batch, answered as the protocol says. */
++ mcp: (message, { protocolVersion = '2025-06-18' } = {}) => call({ method: 'POST', path: '/mcp', json: message, headers: { 'MCP-Protocol-Version': protocolVersion } }),
++ protectedResource: () => call({ path: '/.well-known/oauth-protected-resource' }),
++ face,
++ };
++}
++
++function pick(o, keys) {
++ const out = {};
++ for (const k of keys) if (o && o[k] !== undefined) out[k] = o[k];
++ return out;
++}
++
++module.exports = { createVisionClient, DEFAULT_AUDIENCE };
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 3ceb671..0c768c3 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 69);
++ assert.equal(subpaths.length, 70);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/vision.test.js b/test/vision.test.js
nnew file mode 100644
iindex 0000000..2af9727
---- /dev/null
++++ b/test/vision.test.js
@@@ -0,0 +1,128 @@
++'use strict';
++/** openvibe-sdk/vision: the typed calls against a fake Vision server (multipart bytes, data URLs, JSON, binary). */
++const assert = require('node:assert/strict');
++const { stubServer, send, problem, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createVisionClient, DEFAULT_AUDIENCE } = require('../src/vision');
++
++const ANSWER = {
++ answer: {
++ id: 'vis_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', task: 'ask', mode: 'auto', source: 'upload',
++ image_sha256: 'a'.repeat(64), image_mime: 'image/png', image_bytes: 11,
++ text: 'A desk with a keyboard.', answer: {}, model: 'tesseract', provider: 'local', route: 'local.ocr',
++ reason: 'the local OCR reader answered the read task', cost_usd: 0, latency_ms: 12,
++ created_at: '2026-10-29T10:00:00.000Z',
++ },
++ engine: 'local',
++};
++
++run([
++ ['ask sends bytes as one multipart image and words as JSON', async () => {
++ const srv = await stubServer((req, res) => send(res, 201, ANSWER));
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token' }));
++ const out = await vision.ask(Buffer.from('hello world'), { question: 'What is on the desk?', task: 'ask' });
++ assert.equal(out.answer.text, 'A desk with a keyboard.');
++ const r = srv.requests[0];
++ assert.equal(r.method, 'POST');
++ assert.equal(r.url, '/api/v1/ask');
++ assert.equal(r.headers.authorization, 'Bearer user-token');
++ assert.match(r.headers['content-type'], /^multipart\/form-data; boundary=/);
++ const text = r.body.toString('latin1');
++ assert.match(text, /name="image"; filename="image"/);
++ assert.match(text, /name="question"\r\n\r\nWhat is on the desk\?/);
++ assert.match(text, /hello world/);
++ await srv.close();
++ }],
++
++ ['a public URL and a data URL go as JSON, never as unverified bytes', async () => {
++ const srv = await stubServer((req, res) => send(res, 201, ANSWER));
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url } }));
++ await vision.ask('https://example.com/photo.png', { mode: 'local' });
++ await vision.ask('data:image/png;base64,iVBORw0KGgo=', { mode: 'local' });
++ assert.equal(srv.requests[0].headers['content-type'], 'application/json');
++ assert.deepEqual(JSON.parse(srv.requests[0].body), { image_url: 'https://example.com/photo.png', mode: 'local' });
++ assert.deepEqual(JSON.parse(srv.requests[1].body), { image: 'data:image/png;base64,iVBORw0KGgo=', mode: 'local' });
++ await srv.close();
++ }],
++
++ ['OCR, compare and the UI check carry only their own fields', async () => {
++ const srv = await stubServer((req, res) => {
++ if (req.url === '/api/v1/compare') return send(res, 201, { answer: ANSWER.answer, compare: [{ engine: 'local', ok: true, text: 'hi', cost_usd: 0, latency_ms: 3 }] });
++ if (req.url === '/api/v1/ui-check') return send(res, 201, { check: { id: 'vuc_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', source: 'html', viewport: { w: 1280, h: 800 }, engine: 'static-html', findings: [], created_at: '2026-10-29T10:00:00.000Z' } });
++ return send(res, 201, ANSWER);
++ });
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url } }));
++ await vision.ocr(Buffer.from('png'), { fallback: true, lang: 'eng', model: 'openai:gpt-4o-mini' });
++ assert.match(srv.requests[0].body.toString('latin1'), /name="fallback"\r\n\r\ntrue/);
++ const compared = await vision.compare(Buffer.from('png'), { task: 'describe', models: ['a', 'b'] });
++ assert.equal(compared.compare.length, 1);
++ const checked = await vision.uiCheck({ html: '<!doctype html><p>ok</p>', viewport: { w: 1280, h: 800 } });
++ assert.equal(checked.check.engine, 'static-html');
++ assert.deepEqual(JSON.parse(srv.requests[2].body), { html: '<!doctype html><p>ok</p>', viewport: { w: 1280, h: 800 } });
++ await srv.close();
++ }],
++
++ ['find searches the caller\'s own index by words', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, { answer: { results: [{ item_id: 'media:1', result_url: 'https://openvibe.media/f/x', score: 0.8 }], receipt: { engine: 'local', model: 'CLIP ViT-B/32 q8', cost_usd: 0, ms: 4, confidence: 0.8, retention: 'query not stored' } } }));
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token' }));
++ const out = await vision.find('red bike by the pier');
++ assert.equal(out.answer.results[0].item_id, 'media:1');
++ assert.equal(srv.requests[0].url, '/api/v1/find?q=red+bike+by+the+pier');
++ await srv.close();
++ }],
++
++ ['face tools use the same endpoint and keep the disposition headers', async () => {
++ const srv = await stubServer((req, res) => {
++ if (req.url === '/api/v1/faces/blur') {
++ res.writeHead(200, { 'Content-Type': 'image/png', 'X-Faces-Blurred': '2' });
++ return res.end(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
++ }
++ if (req.url === '/api/v1/faces/consents' && req.method === 'GET') return send(res, 200, [{ id: 'fco_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', feature: 'verify', scope: '', text_version: 'face-verify-2026-10-28', granted_at: '2026-10-29T10:00:00.000Z', revoked_at: null }]);
++ return send(res, 201, { feature: 'verify', scope: '', text_version: 'face-verify-2026-10-28', granted_at: '2026-10-29T10:00:00.000Z' });
++ });
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token' }));
++ const blurred = await vision.face.blur(Buffer.from('png'));
++ assert.equal(blurred.faceCount, 2);
++ assert.equal(blurred.contentType, 'image/png');
++ assert.deepEqual([...new Uint8Array(blurred.bytes)], [0x89, 0x50, 0x4e, 0x47]);
++ const granted = await vision.face.grant('verify', { signed_name: 'Ana', text_version: 'face-verify-2026-10-28' });
++ assert.equal(granted.feature, 'verify');
++ assert.deepEqual(JSON.parse(srv.requests[1].body), { signed_name: 'Ana', text_version: 'face-verify-2026-10-28' });
++ const list = await vision.face.consents();
++ assert.equal(list[0].revoked_at, null);
++ await srv.close();
++ }],
++
++ ['a refusal is the service\'s problem details, not a silent empty answer', async () => {
++ const srv = await stubServer((req, res) => problem(res, 403, 'faces.consent_required', 'Enable verify consent first.'));
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token', retries: 0 }));
++ await assert.rejects(() => vision.face.verify(Buffer.from('png')), (err) => err.status === 403 && err.code === 'faces.consent_required');
++ await srv.close();
++ }],
++
++ ['MCP and the protected-resource document are plain calls on the same origin', async () => {
++ const srv = await stubServer((req, res) => {
++ if (req.url === '/.well-known/oauth-protected-resource') return send(res, 200, { resource: 'https://openvibe.vision', authorization_servers: ['https://openvibe.network'], scopes_supported: ['openid'], bearer_methods_supported: ['header'] });
++ return send(res, 200, { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-06-18' } });
++ });
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url } }));
++ assert.equal(vision.answer.length, 1);
++ const meta = await vision.protectedResource();
++ assert.equal(meta.authorization_servers[0], 'https://openvibe.network');
++ const out = await vision.mcp({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
++ assert.equal(out.result.protocolVersion, '2025-06-18');
++ assert.equal(srv.requests[1].url, '/mcp');
++ assert.equal(srv.requests[1].headers['mcp-protocol-version'], '2025-06-18');
++ await srv.close();
++ }],
++
++ ['the audience is openvibe.vision', async () => {
++ assert.equal(DEFAULT_AUDIENCE, 'openvibe.vision');
++ const srv = await stubServer((req, res) => send(res, 200, { ai: false, configured: [], offered: [], local: ['tesseract'], note: 'x' }));
++ const seen = [];
++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, getToken: async ({ audience }) => { seen.push(audience); return 'svc-token'; } }));
++ await vision.models();
++ assert.deepEqual(seen, ['openvibe.vision']);
++ await srv.close();
++ }],
++]);
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 380a4a8..5633cac 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -8,6 +8,7 @@ import * as community from './community';
import * as bot from './bot';
import * as search from './search';
import * as bench from './bench';
++import * as vision from './vision';
import * as themes from './themes';
import * as chat from './chat';
import * as ai from './ai';
@@@ -27,6 +28,7 @@ export * as community from './community';
export * as bot from './bot';
export * as search from './search';
export * as bench from './bench';
++export * as vision from './vision';
export * as themes from './themes';
export * as chat from './chat';
export * as ai from './ai';
@@@ -37,6 +39,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; themes: typeof themes; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
++ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; bench: typeof bench; vision: typeof vision; themes: typeof themes; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
};
export default sdk;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex cf8470e..6bd50b9 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -10,6 +10,7 @@ export * as community from './community';
export * as bot from './bot';
export * as search from './search';
export * as bench from './bench';
++export * as vision from './vision';
export * as themes from './themes';
export * as chat from './chat';
export * as ai from './ai';
ddiff --git a/types/vision.d.ts b/types/vision.d.ts
nnew file mode 100644
iindex 0000000..c0c414f
---- /dev/null
++++ b/types/vision.d.ts
@@@ -0,0 +1,195 @@
++/** The audience a Vision token is minted for: `openvibe.vision`. */
++export const DEFAULT_AUDIENCE: string;
++
++/** An image as bytes, a data URL or a public URL. */
++export type VisionImage = Blob | ArrayBuffer | ArrayBufferView | string;
++
++export interface VisionReceipt {
++ engine: 'local' | 'routed' | 'local+routed' | 'local+routed description';
++ model: string | null;
++ cost_usd: number;
++ ms: number;
++ confidence: number | null;
++ retention: string;
++}
++
++export interface VisionAnswerRow {
++ id: string;
++ task: 'ask' | 'describe' | 'identify' | 'count' | 'translate' | 'ocr' | 'compare';
++ question?: string | null;
++ mode: 'auto' | 'local' | 'ai';
++ source: 'upload' | 'url' | 'data';
++ source_url?: string | null;
++ image_sha256: string;
++ image_mime: string;
++ image_bytes: number;
++ text: string | null;
++ answer: Record<string, unknown>;
++ model: string | null;
++ provider: string | null;
++ route: string | null;
++ reason: string | null;
++ engine?: string | null;
++ confidence?: number | null;
++ cost_usd: number;
++ latency_ms: number;
++ created_at: string;
++}
++
++export interface VisionCompareSample {
++ engine: 'local' | 'ai';
++ requested_model?: string | null;
++ model?: string | null;
++ provider?: string | null;
++ route?: string | null;
++ ok: boolean;
++ text: string | null;
++ cost_usd: number;
++ latency_ms: number;
++ error_code?: string | null;
++}
++
++export interface VisionAnswerResult {
++ answer: VisionAnswerRow;
++ compare?: VisionCompareSample[];
++ local?: { ok: boolean; model: string | null; confidence: number | null; error_code: string | null };
++}
++
++export interface VisionConsent {
++ id: string;
++ feature: 'verify' | 'find' | 'cast' | 'library';
++ scope: string;
++ text_version: string;
++ granted_at: string;
++ revoked_at: string | null;
++}
++
++export interface VisionConsentGrant {
++ feature: 'verify' | 'find' | 'cast' | 'library';
++ scope: string;
++ text_version: string;
++ granted_at: string;
++}
++
++export interface VisionConsentRequest {
++ signed_name: string;
++ text_version: string;
++ channel?: string;
++ adult?: boolean;
++ region?: string;
++}
++
++export interface VisionFaceTag {
++ id: string;
++ channel: string;
++ media_ref: string;
++ created_at: string;
++}
++
++export interface VisionFaceDetection {
++ count: number;
++ boxes: Array<{ x: number; y: number; width: number; height: number; score: number }>;
++}
++
++export interface VisionFaceLibraryGroup {
++ id: string;
++ media_id: string;
++ group_id: string;
++ label: string | null;
++ excluded: boolean;
++ created_at: string;
++}
++
++export interface VisionUiCheckFinding {
++ kind: 'overflow' | 'cut_off' | 'contrast' | 'overlap' | 'layering';
++ severity: 'high' | 'medium' | 'low';
++ selector: string;
++ detail: string;
++ evidence?: Record<string, unknown>;
++}
++
++export interface VisionUiCheckResult {
++ check: {
++ id: string;
++ source: 'html' | 'url' | 'screenshot';
++ url?: string | null;
++ image_sha256?: string | null;
++ viewport: { w: number; h: number };
++ engine: 'static-html' | 'screenshot';
++ findings: VisionUiCheckFinding[];
++ summary?: Record<string, unknown>;
++ counts?: Record<string, number>;
++ severity?: Record<string, number>;
++ warnings?: string[];
++ summary_text?: string;
++ created_at: string;
++ };
++}
++
++export interface VisionModels {
++ ai: boolean;
++ configured: string[];
++ offered: unknown[];
++ local: string[];
++ note: string;
++}
++
++export interface VisionBenchmarks {
++ count: number;
++ benchmarks: Array<{
++ task: string;
++ engine: 'local' | 'ai';
++ provider: string | null;
++ model: string | null;
++ samples: number;
++ avg_latency_ms: number;
++ avg_cost_usd: number;
++ min_cost_usd: number;
++ max_latency_ms: number;
++ }>;
++}
++
++export interface VisionClientOptions {
++ baseUrl?: string;
++ audience?: string;
++}
++
++export interface VisionClient {
++ ask(image: VisionImage, o?: { question?: string; task?: string; mode?: 'auto' | 'local' | 'ai'; lang?: string; model?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionAnswerResult>;
++ describe(image: VisionImage, o?: { question?: string; task?: string; mode?: 'auto' | 'local' | 'ai'; lang?: string; model?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionAnswerResult>;
++ ocr(image: VisionImage, o?: { lang?: string; fallback?: boolean; model?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionAnswerResult>;
++ compare(image: VisionImage, o?: { task?: string; question?: string; models?: string[]; lang?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionAnswerResult>;
++ alt(image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { alt_text: string; editable: true; receipt: VisionReceipt } }>;
++ index(image: VisionImage, itemId: string, o?: { resultUrl?: string }, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { item_id: string; receipt: VisionReceipt } }>;
++ find(query: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { results: Array<{ item_id: string; result_url: string | null; score: number }>; receipt: VisionReceipt } }>;
++ receipt(image: VisionImage, o?: { fallback?: boolean }, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { store: string | null; items: Array<{ name: string; qty: number; price: number }>; total: number | null; raw_text?: string; receipt: VisionReceipt } }>;
++ product(image: VisionImage, o?: { allow_rerank?: boolean }, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { description: string; embedding: number[]; offers: Array<{ source: string; title: string; url: string; price?: number | null; score?: number }>; warnings: string[]; receipt: VisionReceipt } }>;
++ watch(image: VisionImage, classes: string[], o?: { previousImage?: string; sourceRef?: string }, opts?: { filename?: string; contentType?: string }): Promise<{ answer: { detections: Array<{ class: string; confidence: number; box?: { x: number; y: number; width: number; height: number } | null }>; receipt: VisionReceipt } }>;
++ moments(frames: Array<{ timestamp_ms: number; image?: string; image_url?: string }>, prompts?: string[]): Promise<{ answer: { scene_changes: Array<{ timestamp_ms: number; kind: 'scene_change' }>; candidate_moments: Array<{ timestamp_ms: number; prompt: string; score: number }>; receipt: VisionReceipt } }>;
++ uiCheck(source?: { html?: string; url?: string; image?: VisionImage; imageUrl?: string; viewport?: { w: number; h: number } }): Promise<VisionUiCheckResult>;
++ models(): Promise<VisionModels>;
++ benchmarks(o?: { task?: string; limit?: number }): Promise<VisionBenchmarks>;
++ answer(id: string): Promise<VisionAnswerRow>;
++ uiCheckById(id: string): Promise<VisionUiCheckResult>;
++ mcp(message: unknown, o?: { protocolVersion?: string }): Promise<unknown>;
++ protectedResource(): Promise<{ resource: string; authorization_servers: string[]; scopes_supported: string[]; bearer_methods_supported: string[]; resource_documentation?: string }>;
++ face: {
++ detect(image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<VisionFaceDetection>;
++ blur(image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<{ bytes: ArrayBuffer; contentType: string | null; faceCount: number }>;
++ texts(): Promise<Record<'verify' | 'find' | 'cast' | 'library', { version: string; text: string }>>;
++ consents(): Promise<VisionConsent[]>;
++ grant(feature: 'verify' | 'find' | 'cast' | 'library', body: VisionConsentRequest): Promise<VisionConsentGrant>;
++ revoke(feature: string, o?: { channel?: string }): Promise<{ feature: string; scope: string; revoked_at: string; revoked_by: 'subject'; erased: true }>;
++ enrol(feature: 'verify' | 'find' | 'cast', image: VisionImage, o?: { channel?: string }, opts?: { filename?: string; contentType?: string }): Promise<{ enrolled: true; feature: string; scope: string; liveness: 'not_checked' }>;
++ verify(image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<{ score: number; match: boolean; threshold: number; liveness: 'not_checked' | 'checked' }>;
++ upload(image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<{ id: string; private_link: string; face_count: number }>;
++ media(id: string): Promise<{ bytes: ArrayBuffer; contentType: string | null }>;
++ find(): Promise<{ results: Array<{ private_link: string; score: number }> }>;
++ tags(): Promise<VisionFaceTag[]>;
++ cluster(region: string): Promise<VisionFaceLibraryGroup[]>;
++ groups(): Promise<VisionFaceLibraryGroup[]>;
++ correct(action: 'exclude' | 'merge' | 'split', ids: string[]): Promise<{ action: 'exclude' | 'merge' | 'split'; updated: number }>;
++ };
++}
++
++export function createVisionClient(client: unknown, options?: VisionClientOptions): VisionClient;