openvibers/openvibe.sdk Commits
Prepare SDK 0.72.0 with typed Vision client (#99)
Changes
@@@ -3,6 +3,10 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease 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`.@@@ -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 |@@@ -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",@@@ -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'),@@@ -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 = 'openmodule.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 = 'opemodule.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;@@@ -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"},@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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"@@@ -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",@@@ -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',@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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();++ }],++]);@@@ -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;@@@ -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';nnew file mode 100644@@@ -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;
@@ -3,6 +3,10 @@
@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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.
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)
## 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`.
- **`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`.
@@ -2,14 +2,16 @@
@@ -2,14 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> 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.
**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.
**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.
**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
```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).
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
@@ -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/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/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/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/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/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 |
| `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 |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"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)",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
"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",
"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)",
"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",
"no-looks (openvibe-sdk/no-looks): public AI text never describes a person, one filter and corpus for every service",
@@ -16,6 +16,7 @@ module.exports = {
@@ -16,6 +16,7 @@ module.exports = {
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
bench: require('./src/bench'),
bench: require('./src/bench'),
+ vision: require('./src/vision'),
themes: require('./src/themes'),
themes: require('./src/themes'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
@@ -1,6 +1,6 @@
@@ -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
// 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 */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -23,13 +23,14 @@ module.exports = {
@@ -23,13 +23,14 @@ module.exports = {
bot: __load(15),
bot: __load(15),
search: __load(16),
search: __load(16),
bench: __load(17),
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 ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -44,7 +45,7 @@ const { paginate, offsetPager } = __load(7);
@@ -44,7 +45,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -2192,7 +2193,145 @@ function createBenchClient(client, defaults = {}) {
@@ -2192,7 +2193,145 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
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) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2342,7 +2481,7 @@ function followTheme(fn, opts = {}) {
@@ -2342,7 +2481,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
},
-// ── 19: src/chat.js ─────────────────────────────────────────────────
+// ── 20: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2584,7 +2723,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
@@ -2584,7 +2723,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher };
},
},
-// ── 20: src/ai.js ───────────────────────────────────────────────────
+// ── 21: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2659,7 +2798,7 @@ function createAiClient(client, defaults = {}) {
@@ -2659,7 +2798,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
module.exports = { createAiClient, TERMINAL, OPS };
},
},
-// ── 21: src/jobs.js ─────────────────────────────────────────────────
+// ── 22: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2697,7 +2836,7 @@ function (module, exports) {
@@ -2697,7 +2836,7 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { appendFiles } = __load(22);
+const { appendFiles } = __load(23);
const { parseSSE } = __load(11);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@ -2861,7 +3000,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -2861,7 +3000,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 22: src/core/form.js ────────────────────────────────────────────
+// ── 23: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2895,7 +3034,7 @@ function appendFiles(form, list, where) {
@@ -2895,7 +3034,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
module.exports = { isBlob, isUpload, appendFiles };
},
},
-// ── 23: src/tools.js ────────────────────────────────────────────────
+// ── 24: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2929,8 +3068,8 @@ function (module, exports) {
@@ -2929,8 +3068,8 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
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 enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -3180,7 +3319,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
@@ -3180,7 +3319,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
},
-// ── 24: src/projects.js ─────────────────────────────────────────────
+// ── 25: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3296,7 +3435,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
@@ -3296,7 +3435,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
module.exports = { createProjectsClient };
},
},
-// ── 25: src/geo.js ──────────────────────────────────────────────────
+// ── 26: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -3376,7 +3515,7 @@ export const {
@@ -3376,7 +3515,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, bot,
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;
} = sdk;
export default sdk;
export default sdk;
@@ -310,6 +310,12 @@
@@ -310,6 +310,12 @@
"createVipClient"
"createVipClient"
]
]
},
},
+ "vision": {
+ "object": [
+ "DEFAULT_AUDIENCE",
+ "createVisionClient"
+ ]
+ },
"wiki": {
"wiki": {
"object": [
"object": [
"createWikiClient"
"createWikiClient"
@@ -796,6 +802,10 @@
@@ -796,6 +802,10 @@
"createVipCache": "function",
"createVipCache": "function",
"createVipClient": "function"
"createVipClient": "function"
},
},
+ "openvibe-sdk/vision": {
+ "DEFAULT_AUDIENCE": "string",
+ "createVisionClient": "function"
+ },
"openvibe-sdk/wiki": {
"openvibe-sdk/wiki": {
"createWikiClient": "function"
"createWikiClient": "function"
},
},
@@ -6,12 +6,12 @@ export const {
@@ -6,12 +6,12 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
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;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
@@ -19,6 +19,7 @@ module.exports = {
@@ -19,6 +19,7 @@ module.exports = {
bot: require('./src/bot'),
bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
bench: require('./src/bench'),
bench: require('./src/bench'),
+ vision: require('./src/vision'),
themes: require('./src/themes'),
themes: require('./src/themes'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.71.0",
+ "version": "0.72.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.71.0",
+ "version": "0.72.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -377,6 +377,11 @@
@@ -377,6 +377,11 @@
"import": "./esm/no-looks.mjs",
"import": "./esm/no-looks.mjs",
"default": "./src/no-looks.js"
"default": "./src/no-looks.js"
},
},
+ "./vision": {
+ "types": "./types/vision.d.ts",
+ "import": "./esm/vision.mjs",
+ "default": "./src/vision.js"
+ },
"./front-page": {
"./front-page": {
"types": "./types/front-page.d.ts",
"types": "./types/front-page.d.ts",
"import": "./esm/front-page.mjs",
"import": "./esm/front-page.mjs",
@@ -70,6 +70,7 @@ const ENTRIES = {
@@ -70,6 +70,7 @@ const ENTRIES = {
'host.mjs': '../src/host.js',
'host.mjs': '../src/host.js',
'news.mjs': '../src/news.js',
'news.mjs': '../src/news.js',
'no-looks.mjs': '../src/no-looks.js',
'no-looks.mjs': '../src/no-looks.js',
+ 'vision.mjs': '../src/vision.js',
'front-page.mjs': '../src/front-page.js',
'front-page.mjs': '../src/front-page.js',
'food.mjs': '../src/food.js',
'food.mjs': '../src/food.js',
'actor.mjs': '../src/actor.js',
'actor.mjs': '../src/actor.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -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 };
@@ -24,7 +24,7 @@ run([
@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
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.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');
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([
@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
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.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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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();
+ }],
+]);
@@ -8,6 +8,7 @@ import * as community from './community';
@@ -8,6 +8,7 @@ import * as community from './community';
import * as bot from './bot';
import * as bot from './bot';
import * as search from './search';
import * as search from './search';
import * as bench from './bench';
import * as bench from './bench';
+import * as vision from './vision';
import * as themes from './themes';
import * as themes from './themes';
import * as chat from './chat';
import * as chat from './chat';
import * as ai from './ai';
import * as ai from './ai';
@@ -27,6 +28,7 @@ export * as community from './community';
@@ -27,6 +28,7 @@ export * as community from './community';
export * as bot from './bot';
export * as bot from './bot';
export * as search from './search';
export * as search from './search';
export * as bench from './bench';
export * as bench from './bench';
+export * as vision from './vision';
export * as themes from './themes';
export * as themes from './themes';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';
@@ -37,6 +39,6 @@ export * as geo from './geo';
@@ -37,6 +39,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
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;
export default sdk;
@@ -10,6 +10,7 @@ export * as community from './community';
@@ -10,6 +10,7 @@ export * as community from './community';
export * as bot from './bot';
export * as bot from './bot';
export * as search from './search';
export * as search from './search';
export * as bench from './bench';
export * as bench from './bench';
+export * as vision from './vision';
export * as themes from './themes';
export * as themes from './themes';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -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;