openvibers/openvibe.sdk Commits
0.55.0: Food, Actor, Run, Video playlists, Work and Zone in the SDK (openvibe-sdk/food, /actor, /run, /video, /work, /zone) (#66)
Changes
@@@ -3,6 +3,25 @@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.55.0 (2026-10-20)++++- Six more products through the SDK, each a server module over the routes its capabilities name in Contracts:++ - **`openvibe-sdk/food`** (`createFoodClient`): places to get food, the food list with prices, meal plans, a++ person's pantry with its "what can I make" matches, and AI recipe ideas.++ - **`openvibe-sdk/actor`** (`createActorClient`): tasks for AI agent systems (create, list, get, cancel, and++ `tasks.events`, the SSE stream until the task ends), the public agent list and dry-run routing, and a person's++ own agents with their runs and memory.++ - **`openvibe-sdk/run`** (`createRunClient`): jobs on worker nodes (submit with an Idempotency-Key, list, get,++ cancel, `jobs.events` until `end`, a browser stream ticket), `forProject` for X-OV-Project.++ - **`openvibe-sdk/video`** (`createVideoClient`): OpenVibe.Video's playlists of recordings and clips, and a++ person's public playlists.++ - **`openvibe-sdk/work`** (`createWorkClient`): the job board's search and sources, saved searches, tracked++ applications and the agent's help with one.++ - **`openvibe-sdk/zone`** (`createZoneClient`): a project's object zones and their usage.++- A write that makes something new each time (a plan, recipe ideas, an agent, a run, a memory note, a playlist or an++ item in it, a saved search, an application without a listing) is never retried on its own. Keyed writes (Run's++ submit, Zone's create, an Actor task with an idempotency_key, an application for a listing) and changes are.++## 0.54.0 (2026-10-20)- **`openvibe-sdk/tips`**: OpenVibe.Tips through the SDK (it had 19 capabilities and no module). `createTipsClient(client)`@@@ -2,14 +2,14 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.54.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.55.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.**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.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.54.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.55.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).@@@ -190,6 +190,12 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried || `openvibe-sdk/inventory` | server | `createInventoryClient(client)`: OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: `kinds.list/get`, `definitions.list({kind, issuer})/get`, `people.items(subject)`, `people.equipped(subject)`, `equipped([subjects])` (≤ 100, for chat renders). The person's own: `me.items()`, `me.equip({kind, slot, instance_id | null})`. An issuer's: `definitions.create/update`, `grants.create({definition_id, subject, idempotency_key})` → `{ instance, created }` (retry-safe on the key), `instances.consume/revoke`. `forPerson(usr_…)` sends `X-OV-Subject` for a service acting for a person. Audience `openvibe.inventory` || `openvibe-sdk/tips` | server | `createTipsClient(client)`: OpenVibe.Tips' `/api/v1`. A creator's page: `profiles.get/update/totals/sounds/supporters`. Tipping: `tip({creator, amount, message?, goal_id?, pay_with})` → `{ interaction, checkout_url, checkout_ref }`, `send.message/tts/media/sound`. Receipts: `interactions.get/list/recordExternal/hide/restore`. `goals.list/get/create/update/close`, `moderation.queue/log/moderators.*`, `overlays.tokens.*/configs.*/simulate`, `me.export/erase`. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience `openvibe.tips` |++| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |++| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |++| `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` |++| `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) |++| `openvibe-sdk/work` | server | `createWorkClient(client)`: OpenVibe.Work. Public: `jobs.search({q, remote, type, location, cursor})`, `jobs.get`, `sources()`. A person's: `savedSearches.list/create/remove`, `applications.list/create/get/update/remove` (also an app with `work.application.manage`; tracking a listing twice answers the first), `applications.assist(id, {ask})` (their Actor agent helps). Audience `openvibe.work` |++| `openvibe-sdk/zone` | server | `createZoneClient(client)`: OpenVibe.Zone, a project's object zones in Media. `zones.create(prj_…, {env, name, description, quota_bytes})` (Idempotency-Key), `zones.list(prj_…, {env, include_deleted})`, `zones.get/update/remove(zon_…, {recursive})`, `zones.usage(zon_…)`. Project members and the project's apps only. Audience `openvibe.zone` || `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` || `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it || `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |@@@ -470,7 +476,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.--- **Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.++- **Billing and Games** from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Products whose capabilities are all first-party (Blog, Coupons, Deals, News, Reviews, Space, Trade, Watch, Wiki and others) have no module yet: there is nothing an outside app may call. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.- **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.54.0",++ "version": "0.55.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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.54.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.55.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, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,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.54.0';++const SDK_VERSION = '0.55.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/actor.js. Do not edit.++import cjs from '../src/actor.js';++++export const {++ createActorClient,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/food.js. Do not edit.++import cjs from '../src/food.js';++++export const {++ createFoodClient,++} = cjs;++export default cjs;@@@ -8,7 +8,8 @@ export const {registry, identity, modules, events, realtime, media,community, bot, search, chat, ai, jobs,tools, projects, resources, vip, quest, live,-- inventory, tips, play, download, openre, geo,++ inventory, tips, 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/run.js. Do not edit.++import cjs from '../src/run.js';++++export const {++ createRunClient, RUN_JOB_STATES,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/video.js. Do not edit.++import cjs from '../src/video.js';++++export const {++ createVideoClient,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/work.js. Do not edit.++import cjs from '../src/work.js';++++export const {++ createWorkClient, WORK_APPLICATION_STATUSES,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/zone.js. Do not edit.++import cjs from '../src/zone.js';++++export const {++ createZoneClient,++} = cjs;++export default cjs;@@@ -29,6 +29,12 @@ module.exports = {live: require('./src/live'),inventory: require('./src/inventory'),tips: require('./src/tips'),++ food: require('./src/food'),++ actor: require('./src/actor'),++ run: require('./src/run'),++ video: require('./src/video'),++ work: require('./src/work'),++ zone: require('./src/zone'),play: require('./src/play'),download: require('./src/download'),openre: require('./src/openre'),@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.54.0",++ "version": "0.55.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.54.0",++ "version": "0.55.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.54.0",++ "version": "0.55.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",@@@ -272,6 +272,42 @@"import": "./esm/tips.mjs","default": "./src/tips.js"},++ "./food": {++ "types": "./types/food.d.ts",++ "browser": null,++ "import": "./esm/food.mjs",++ "default": "./src/food.js"++ },++ "./actor": {++ "types": "./types/actor.d.ts",++ "browser": null,++ "import": "./esm/actor.mjs",++ "default": "./src/actor.js"++ },++ "./run": {++ "types": "./types/run.d.ts",++ "browser": null,++ "import": "./esm/run.mjs",++ "default": "./src/run.js"++ },++ "./video": {++ "types": "./types/video.d.ts",++ "browser": null,++ "import": "./esm/video.mjs",++ "default": "./src/video.js"++ },++ "./work": {++ "types": "./types/work.d.ts",++ "browser": null,++ "import": "./esm/work.mjs",++ "default": "./src/work.js"++ },++ "./zone": {++ "types": "./types/zone.d.ts",++ "browser": null,++ "import": "./esm/zone.mjs",++ "default": "./src/zone.js"++ },"./vip": {"types": "./types/vip.d.ts","browser": null,@@@ -58,6 +58,12 @@ const ENTRIES = {'live.mjs': '../src/live.js','inventory.mjs': '../src/inventory.js','tips.mjs': '../src/tips.js',++ 'food.mjs': '../src/food.js',++ 'actor.mjs': '../src/actor.js',++ 'run.mjs': '../src/run.js',++ 'video.mjs': '../src/video.js',++ 'work.mjs': '../src/work.js',++ 'zone.mjs': '../src/zone.js','vip.mjs': '../src/vip.js','frame.mjs': '../src/frame.js','service.mjs': '../src/service/index.js',nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * openvibe-sdk/actor — OpenVibe.Actor for apps: hand a task to an AI agent system and follow it, and keep a person's++ * own agents (standing instructions, a schedule, a budget per run, what they remember). Routes at++ * https://openvibe.actor/api/v1; tokens for audience openvibe.actor.++ *++ * const actor = createActorClient(createClient({ tokenProvider }));++ * const { agents } = await actor.agents.list(); // the agent systems (public)++ * const plan = await actor.route({ task: 'Summarize this repo', mode: 'cheapest' }); // dry run: which agent, what cost++ * const task = await actor.tasks.create({ task: 'Summarize …', idempotency_key: 'job-42' });++ * for await (const e of actor.tasks.events(task.id)) console.log(e.event, e.data); // until the task ends++ * const mine = await actor.me.agents.create({ name: 'Morning brief', instructions: '…' });++ * await actor.me.agents.run(mine.id, { input: 'today' });++ * await actor.me.agents.memory.add(mine.id, 'I live in Portland');++ *++ * A task is its requester's own (actor.task-create-request@1: task, mode, budget, agent, idempotency_key, webhooks).++ * When it names webhooks, the answer that creates it carries webhook_secret, once. tasks.create is retried only when++ * the body has an idempotency_key (Actor answers a repeat with the first task); the other writes are never retried on++ * their own. A person's agents need the person's own token (an app's or another person's is 404).++ * Errors are OpenVibeError: actor.task.invalid, actor.budget.over_tier, actor.idempotency.conflict (409),++ * actor.webhook.refused, actor.agent.invalid, actor.agent.limit (429), actor.agent.not_found, actor.task.not_found, …++ */++const { parseSSE } = require('./realtime');++++const enc = encodeURIComponent;++const ID_RE = { // Actor's own rules++ task: /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/,++ agent: /^agt_[0-9A-HJKMNP-TV-Z]{26}$/,++ memory: /^mem_[0-9A-HJKMNP-TV-Z]{26}$/,++};++++function createActorClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createActorClient: pass an openvibe-sdk/core client');++ const at = { service: 'actor', baseUrl, audience: 'openvibe.actor' };++ const call = (opts) => client.json({ ...at, ...opts });++ const once = (opts) => call({ ...opts, idempotencyKey: false });++ const id = (kind, v) => {++ if (!ID_RE[kind].test(String(v || ''))) throw new TypeError(`actor: a${kind === 'agent' ? 'n' : ''} ${kind} id is ${ID_RE[kind].source.slice(1, 5)}…`);++ return enc(String(v));++ };++ const agentPath = (agent, rest = '') => `/api/v1/me/agents/${id('agent', agent)}${rest}`;++++ /**++ * A task's events as they happen (SSE: { id, event, data }), the backlog after lastEventId first; it ends when++ * Actor closes the stream after the task's last event. Pass the last id back as lastEventId to pick up again.++ */++ async function* events(taskId, { lastEventId = 0, signal } = {}) {++ const res = await client.request({++ ...at, path: `/api/v1/tasks/${id('task', taskId)}/events`, responseType: 'response', retries: 0, signal,++ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(Number(lastEventId) || 0) },++ });++ for await (const msg of parseSSE(res.data.body)) {++ let data = null;++ try { data = JSON.parse(msg.data); } catch { data = msg.data; }++ const n = Number(msg.id);++ yield { id: Number.isFinite(n) && msg.id !== undefined && msg.id !== '' ? n : null, event: msg.event, data };++ }++ }++++ return {++ agents: {++ /** The agent systems a task can go to, with what they are good at and cost (public). */++ list: ({ signal } = {}) => call({ path: '/api/v1/agents', signal }),++ },++ /** A dry run (public): the task's class and the agent and budget Actor would pick, nothing started. */++ route: ({ task, mode, agent } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/route', json: { task, mode, agent }, idempotent: true, signal }),++ tasks: {++ /** Start a task (201, or 200 for an idempotent repeat) → the task (platform.task@1). */++ create: (body, { signal } = {}) => {++ if (!body || typeof body.task !== 'string' || !body.task.trim()) throw new TypeError('actor.tasks.create: task is the instruction in plain words');++ return body.idempotency_key ? call({ method: 'POST', path: '/api/v1/tasks', json: body, idempotent: true, signal }) : once({ method: 'POST', path: '/api/v1/tasks', json: body, signal });++ },++ list: ({ limit, before, signal } = {}) => call({ path: '/api/v1/tasks', query: { limit, before }, signal }),++ get: (taskId, { signal } = {}) => call({ path: `/api/v1/tasks/${id('task', taskId)}`, signal }),++ /** Ask a task to stop (202 while it winds down, 200 once ended); asking twice is harmless. */++ cancel: (taskId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/tasks/${id('task', taskId)}/cancel`, idempotent: true, signal }),++ events,++ },++ me: {++ agents: {++ list: ({ signal } = {}) => call({ path: '/api/v1/me/agents', signal }),++ /** A personal agent: name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, … */++ create: (agent, { signal } = {}) => once({ method: 'POST', path: '/api/v1/me/agents', json: agent, signal }),++ get: (agent, { signal } = {}) => call({ path: agentPath(agent), signal }),++ update: (agent, patch, { signal } = {}) => call({ method: 'PATCH', path: agentPath(agent), json: patch, idempotent: true, signal }),++ remove: (agent, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent), signal }),++ /** Run it now with an input → the task it started (follow it with tasks.events). */++ run: (agent, { input } = {}, { signal } = {}) => once({ method: 'POST', path: agentPath(agent, '/run'), json: { input: input == null ? '' : String(input) }, signal }),++ runs: (agent, { limit, before, signal } = {}) => call({ path: agentPath(agent, '/runs'), query: { limit, before }, signal }),++ memory: {++ list: (agent, { signal } = {}) => call({ path: agentPath(agent, '/memory'), signal }),++ /** Something for the agent to remember (1–1000 characters). */++ add: (agent, text, { signal } = {}) => once({ method: 'POST', path: agentPath(agent, '/memory'), json: { text }, signal }),++ update: (agent, note, text, { signal } = {}) => call({ method: 'PATCH', path: agentPath(agent, `/memory/${id('memory', note)}`), json: { text }, idempotent: true, signal }),++ remove: (agent, note, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent, `/memory/${id('memory', note)}`), signal }),++ /** Forget everything it remembers. */++ clear: (agent, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent, '/memory'), signal }),++ },++ },++ },++ };++}++++module.exports = { createActorClient };@@@ -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.54.0';++const SDK_VERSION = '0.55.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * openvibe-sdk/food — OpenVibe.Food for apps: places to get food near you (food banks, pantries, free meals; from++ * OpenStreetMap), the food list with store prices, meal plans, a person's pantry with "what can I make", and recipe++ * ideas from the network's AI. Routes at https://openvibe.food/api/v1; tokens for audience openvibe.food.++ *++ * const food = createFoodClient(createClient({ tokenProvider }));++ * const near = await food.places({ kind: 'foodbank', lat: 45.52, lon: -122.68, radius: 10 }); // public++ * const { foods } = await food.foods.list({ group: 'vegetables', q: 'carrot' }); // public++ * const plan = await food.plans.create({ people: 2, days: 7, budget: 80, title: 'This week' }); // food.plan.write++ * await food.pantry.set(['rice', 'black-beans', 'onions']); // food.pantry.write++ * const { suggestions } = await food.pantry.get(); // { can_make, almost }++ * const ideas = await food.recipes.suggest({ ask: 'something quick', people: 2 }); // 10 a day++ *++ * A plan, a pantry and recipe ideas are the caller's own: a person's token, or an app holding the capability. A plan++ * is deterministic (all six MyPlate groups, a shopping list, the cheapest store per line, a budget as a hard cap); the++ * pantry's suggestions are matches against basic recipes, not dietary advice. Creating a plan or asking for recipe++ * ideas makes a new one each time, so neither is retried on its own; setting the pantry is a PUT and is.++ * Errors are OpenVibeError: food.plan.invalid, food.pantry.invalid, food.pantry.unknown_item, food.pantry_empty,++ * food.recipes_daily_limit (429), food.ai_off (503), food.plan.not_found, …++ */++const enc = encodeURIComponent;++const PLAN_ID_RE = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/; // Food's own rule++++function createFoodClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createFoodClient: pass an openvibe-sdk/core client');++ const at = { service: 'food', baseUrl, audience: 'openvibe.food' };++ const call = (opts) => client.json({ ...at, ...opts });++ const planId = (id) => {++ if (!PLAN_ID_RE.test(String(id || ''))) throw new TypeError('food: a plan is a pln_ id');++ return enc(String(id));++ };++++ return {++ /** Places to get food (public): kind foodbank (default) or another kind Food lists, near lat/lon or a place q. */++ places: ({ kind, q, lat, lon, radius, limit, signal } = {}) => call({ path: '/api/v1/places', query: { kind, q, lat, lon, radius, limit }, signal }),++ foods: {++ /** The food list with store prices (public), by MyPlate group and/or a search. */++ list: ({ group, q, signal } = {}) => call({ path: '/api/v1/foods', query: { group, q }, signal }),++ get: (slug, { signal } = {}) => call({ path: `/api/v1/foods/${enc(String(slug))}`, signal }),++ },++ plans: {++ /** A meal plan for 1–14 days (food.plan-create-request@1) → the plan, saved as the caller's. */++ create: ({ people, days, budget, title } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/plans', json: { people, days, budget, title }, idempotencyKey: false, signal }),++ list: ({ limit, signal } = {}) => call({ path: '/api/v1/plans', query: { limit }, signal }),++ get: (id, { signal } = {}) => call({ path: `/api/v1/plans/${planId(id)}`, signal }),++ },++ pantry: {++ /** The caller's pantry: { items, foods, suggestions: { can_make, almost }, note }. */++ get: ({ signal } = {}) => call({ path: '/api/v1/pantry', signal }),++ /** Replace the pantry with these food slugs (at most 200; GET foods lists them). */++ set: (items, { signal } = {}) => {++ if (!Array.isArray(items)) throw new TypeError('food.pantry.set: items is an array of food slugs');++ if (items.length > 200) throw new TypeError('food.pantry.set: at most 200 items');++ return call({ method: 'PUT', path: '/api/v1/pantry', json: { items: items.map(String) }, signal });++ },++ },++ recipes: {++ /** Recipe ideas from the pantry through OpenVibe.AI (10 a day per person); ask and people are optional. */++ suggest: ({ ask, people } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/recipes/suggest', json: { ask, people }, idempotencyKey: false, signal }),++ /** The caller's last 20 suggestions. */++ list: ({ signal } = {}) => call({ path: '/api/v1/recipes', signal }),++ },++ };++}++++module.exports = { createFoodClient };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * openvibe-sdk/run — OpenVibe.Run for services and apps: run a job on a worker node (a machine that offers++ * worker:<class>, placed by Run and dispatched through Bot), follow it and cancel it. Routes at++ * https://openvibe.run/api/v1; tokens for audience openvibe.run that name a project (the job is the project's; a++ * first-party service acting for a person names the project with X-OV-Project, see `forProject`).++ *++ * const run = createRunClient(createClient({ tokenProvider }));++ * const { job } = await run.jobs.submit({ class: 'function', artifact: { name: 'thumbnailer', version: '1.2.0' }, args: { width: 320 }, limits: { wall_ms: 120000, cpu_ms: 60000, mem_bytes: 512e6 } });++ * for await (const e of run.jobs.events(job.id)) console.log(e.event, e.data); // until the job's `end` event++ * const { jobs, next_cursor } = await run.jobs.list({ state: 'running' });++ * await run.jobs.cancel(job.id); // a request: cancelled once the node stops++ * const { stream_url } = await run.jobs.streamTicket(job.id); // a two-minute, one-use URL for a browser's EventSource++ *++ * submit carries an Idempotency-Key (Run answers a repeat with the first job, 200), so it is retried safely; pass++ * idempotency_key in the body to choose it. Cancelling asks the node to stop; the job reaches `cancelled` when it has.++ * Errors are OpenVibeError: run.invalid_request (400), run.project_required (403), run.job_not_found (404, also for++ * another project's job), run.idempotency.conflict (409), run.args_too_large (413), run.limits.exceeded,++ * run.invalid_input (422), run.stream.busy / run.stream.disabled (503), …++ */++const { parseSSE } = require('./realtime');++++const enc = encodeURIComponent;++const JOB_ID_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/; // Run's own rule++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const STATES = ['queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired'];++++function createRunClient(client, { baseUrl, project } = {}) {++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createRunClient: pass an openvibe-sdk/core client');++ if (project != null && !PROJECT_RE.test(String(project))) throw new TypeError('createRunClient: project is a prj_ id');++ const at = { service: 'run', baseUrl, audience: 'openvibe.run', ...(project ? { headers: { 'X-OV-Project': String(project) } } : {}) };++ const call = (opts) => client.json({ ...at, ...opts, headers: { ...(at.headers || {}), ...(opts.headers || {}) } });++ const job = (v) => {++ if (!JOB_ID_RE.test(String(v || ''))) throw new TypeError('run: a job is a job_ id');++ return enc(String(v));++ };++++ /**++ * The job's events as they happen (SSE run.job-stream-event@1: { id, event, data }), replayed after lastEventId++ * first; it ends with the job's `end` event, after which Run closes the stream. Needs run.job.stream.++ */++ async function* events(jobId, { lastEventId = 0, signal } = {}) {++ const res = await client.request({++ ...at, path: `/api/v1/jobs/${job(jobId)}/stream`, responseType: 'response', retries: 0, signal,++ headers: { ...(at.headers || {}), Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(Number(lastEventId) || 0) },++ });++ for await (const msg of parseSSE(res.data.body)) {++ let data = null;++ try { data = JSON.parse(msg.data); } catch { data = msg.data; }++ const n = Number(msg.id);++ yield { id: Number.isFinite(n) && msg.id !== undefined && msg.id !== '' ? n : null, event: msg.event, data };++ if (msg.event === 'end') return;++ }++ }++++ return {++ jobs: {++ /** Submit a job (run.job-create-request@1: class and limits required) → { job } (201, or 200 for a repeat). */++ submit: (body, { idempotencyKey, signal } = {}) => {++ if (!body || typeof body.class !== 'string' || !body.limits) throw new TypeError('run.jobs.submit: class and limits are required');++ const key = idempotencyKey || body.idempotency_key;++ return call({ method: 'POST', path: '/api/v1/jobs', json: body, ...(key ? { idempotencyKey: String(key) } : {}), signal });++ },++ /** The project's jobs, newest first: { jobs, next_cursor }. */++ list: ({ state, limit, cursor, signal } = {}) => {++ if (state != null && !STATES.includes(state)) throw new TypeError(`run.jobs.list: state is one of ${STATES.join(', ')}`);++ return call({ path: '/api/v1/jobs', query: { state, limit, cursor }, signal });++ },++ get: (jobId, { signal } = {}) => call({ path: `/api/v1/jobs/${job(jobId)}`, signal }),++ /** Ask the job to stop; asking twice is harmless. */++ cancel: (jobId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/jobs/${job(jobId)}/cancel`, idempotent: true, signal }),++ /** A one-use ticket for a browser: { ticket, expires_at, expires_in, stream_url, job_id }. */++ streamTicket: (jobId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/jobs/${job(jobId)}/stream/ticket`, idempotent: true, signal }),++ events,++ },++ /** The same client for a project named by X-OV-Project (a first-party service acting for one of its admins). */++ forProject: (p) => createRunClient(client, { baseUrl, project: p }),++ };++}++++module.exports = { createRunClient, RUN_JOB_STATES: STATES };nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * openvibe-sdk/video — OpenVibe.Video's playlists for apps: a person's or an app's playlists of recordings and clips++ * from openvibe.video (stream recordings and clips from OpenVibe.Live, and uploads), and anyone's public playlists.++ * Routes at https://openvibe.video/api/v1/video; the media hub's tokens are for audience openvibe.download (one++ * service, one audience, shared with the drive).++ *++ * const video = createVideoClient(createClient({ tokenProvider }));++ * const { playlist } = await video.playlists.create({ title: 'Best of October', visibility: 'public' });++ * await video.playlists.items.add(playlist.id, { kind: 'vod', id: 5579 });++ * await video.playlists.items.add(playlist.id, { kind: 'clip', id: 1203 });++ * await video.playlists.items.reorder(playlist.id, [{ kind: 'clip', id: 1203 }, { kind: 'vod', id: 5579 }]);++ * const { playlist: p, items } = await video.playlists.get(playlist.id); // public and unlisted: anyone++ * const theirs = await video.users.playlists('goosely'); // a person's public playlists++ *++ * Visibility is public, unlisted or private; a private playlist is 404 to everyone but its owner. An app's playlists++ * are its own (owner app:…), recorded with its project. Creating a playlist or adding an item makes a new one each++ * time, so neither is retried on its own; changes and deletes are.++ * Errors are OpenVibeError: playlist.not_found, playlist.item_not_found (404), playlist.item_duplicate (409),++ * playlist.invalid (422), capability.denied, user.not_found, …++ */++const enc = encodeURIComponent;++const PLAYLIST_ID_RE = /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/; // the media hub's own rules++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;++const KINDS = ['vod', 'clip'];++++function createVideoClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createVideoClient: pass an openvibe-sdk/core client');++ const at = { service: 'media-hub', baseUrl, audience: 'openvibe.download' };++ const call = (opts) => client.json({ ...at, ...opts });++ const playlist = (id) => {++ if (!PLAYLIST_ID_RE.test(String(id || ''))) throw new TypeError('video: a playlist is a vpl_ id');++ return `/api/v1/video/playlists/${enc(String(id))}`;++ };++ const item = ({ kind, id } = {}) => {++ if (!KINDS.includes(kind)) throw new TypeError("video: an item's kind is 'vod' or 'clip'");++ if (!Number.isInteger(Number(id)) || Number(id) < 1) throw new TypeError("video: an item's id is the recording's or clip's number");++ return { kind, id: Number(id) };++ };++++ return {++ playlists: {++ /** The caller's own playlists (a person, or an app with video.playlist.read). */++ list: ({ signal } = {}) => call({ path: '/api/v1/video/playlists', signal }),++ /** { title, description?, visibility? } → { playlist }. */++ create: ({ title, description, visibility } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/video/playlists', json: { title, description, visibility }, idempotencyKey: false, signal }),++ /** One playlist and its items, in order: { playlist (with owner), items: [{ kind, id, position, added_at }] }. */++ get: (id, { signal } = {}) => call({ path: playlist(id), signal }),++ update: (id, patch, { signal } = {}) => call({ method: 'PATCH', path: playlist(id), json: patch, idempotent: true, signal }),++ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: playlist(id), signal }),++ items: {++ add: (id, it, { signal } = {}) => call({ method: 'POST', path: `${playlist(id)}/items`, json: item(it), idempotencyKey: false, signal }),++ /** The full new order: every item, each once → { items }. */++ reorder: (id, items, { signal } = {}) => {++ if (!Array.isArray(items)) throw new TypeError('video.playlists.items.reorder: items is the full ordered list');++ return call({ method: 'PATCH', path: `${playlist(id)}/items`, json: { items: items.map(item) }, idempotent: true, signal });++ },++ remove: (id, it, { signal } = {}) => {++ const { kind, id: itemId } = item(it);++ return call({ method: 'DELETE', path: `${playlist(id)}/items/${kind}/${itemId}`, signal });++ },++ },++ },++ users: {++ /** A person's public playlists, newest first: { playlists, next_cursor }. */++ playlists: (username, { limit, cursor, signal } = {}) => {++ if (!USERNAME_RE.test(String(username || ''))) throw new TypeError('video.users.playlists: a username');++ return call({ path: `/api/v1/video/users/${enc(String(username))}/playlists`, query: { limit, cursor }, signal });++ },++ },++ };++}++++module.exports = { createVideoClient };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * openvibe-sdk/work — OpenVibe.Work for apps: search the job board (listings gathered from public job boards, each++ * linking to the board's own posting), the sources it reads, a person's saved searches, and the applications they++ * track. Routes at https://openvibe.work/api/v1; tokens for audience openvibe.work.++ *++ * const work = createWorkClient(createClient({ tokenProvider }));++ * const { jobs, next, facets } = await work.jobs.search({ q: 'node', remote: true }); // public++ * const { sources } = await work.sources(); // public: what Work reads, how often++ * const app = await work.applications.create({ listing_id: jobs[0].id, status: 'saved' });++ * await work.applications.update(app.id, { status: 'applied', applied_at: new Date().toISOString() });++ * await work.savedSearches.create({ q: 'robotics', remote: true }); // a person's token only++ *++ * Applications are a person's own: their token, or an app holding work.application.manage. Saved searches and the++ * agent's help with an application (`applications.assist`) are the person's alone. Tracking a listing a second time++ * answers the application already tracked (200), so creating one from a listing is retried safely; without a listing++ * it is never retried on its own.++ * Errors are OpenVibeError: work.jobs.cursor_invalid, work.applications.status_invalid, work.applications_full (409),++ * work.applications.not_found, work.saved_searches.empty, work.saved_searches.limit (429),++ * work.saved_searches.person_only, work.actor_unavailable (503), …++ */++const enc = encodeURIComponent;++const APPLICATION_ID_RE = /^wap_[0-9A-HJKMNP-TV-Z]{26}$/; // Work's own rule++const STATUSES = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];++++function createWorkClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createWorkClient: pass an openvibe-sdk/core client');++ const at = { service: 'work', baseUrl, audience: 'openvibe.work' };++ const call = (opts) => client.json({ ...at, ...opts });++ const application = (id) => {++ if (!APPLICATION_ID_RE.test(String(id || ''))) throw new TypeError('work: an application is a wap_ id');++ return `/api/v1/me/applications/${enc(String(id))}`;++ };++ const status = (s) => {++ if (s != null && !STATUSES.includes(s)) throw new TypeError(`work: status is one of ${STATUSES.join(', ')}`);++ return s;++ };++ const bool = (v) => (v == null ? undefined : String(Boolean(v) && v !== 'false'));++++ return {++ jobs: {++ /** Search the listings (public): { jobs, next, count, filters, facets, sources }. Page with cursor = next. */++ search: ({ q, remote, type, location, limit, cursor, signal } = {}) => call({ path: '/api/v1/jobs', query: { q, remote: bool(remote), type, location, limit, cursor }, signal }),++ get: (id, { signal } = {}) => call({ path: `/api/v1/jobs/${enc(String(id))}`, signal }),++ },++ /** The boards Work reads, their terms and attribution, and when each was last fetched (public). */++ sources: ({ signal } = {}) => call({ path: '/api/v1/sources', signal }),++ savedSearches: {++ list: ({ signal } = {}) => call({ path: '/api/v1/saved-searches', signal }),++ /** At least one of q, remote, type or location. */++ create: ({ q, remote, type, location } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/saved-searches', json: { q, remote: bool(remote), type, location }, idempotencyKey: false, signal }),++ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/saved-searches/${enc(String(id))}`, signal }),++ },++ applications: {++ /** The person's tracked applications, newest first: { applications, next }. */++ list: ({ status: st, limit, cursor, signal } = {}) => call({ path: '/api/v1/me/applications', query: { status: status(st), limit, cursor }, signal }),++ /** Track one (work.application-write-request@1): a listing_id, or a title/company/url of your own. */++ create: (fields, { signal } = {}) => {++ if (!fields || typeof fields !== 'object') throw new TypeError('work.applications.create: the application fields');++ status(fields.status);++ return call({ method: 'POST', path: '/api/v1/me/applications', json: fields, ...(fields.listing_id ? { idempotent: true } : { idempotencyKey: false }), signal });++ },++ get: (id, { signal } = {}) => call({ path: application(id), signal }),++ update: (id, patch, { signal } = {}) => {++ status(patch && patch.status);++ return call({ method: 'PATCH', path: application(id), json: patch, idempotent: true, signal });++ },++ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: application(id), signal }),++ /** Ask the person's OpenVibe.Actor agent for help with it (a cover letter, prep): { task_id, task_url }. */++ assist: (id, { ask } = {}, { signal } = {}) => call({ method: 'POST', path: `${application(id)}/assist`, json: { ask }, idempotencyKey: false, signal }),++ },++ };++}++++module.exports = { createWorkClient, WORK_APPLICATION_STATUSES: STATUSES };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * openvibe-sdk/zone — OpenVibe.Zone for apps: a developer project's object zones (named storage spaces in++ * OpenVibe.Media, one set per environment, each with an optional quota) and their usage. Routes at++ * https://openvibe.zone/api/v1; tokens for audience openvibe.zone. Upload into a zone with openvibe-sdk/media's++ * object API.++ *++ * const zone = createZoneClient(createClient({ tokenProvider }));++ * const z = await zone.zones.create('prj_01…', { env: 'production', name: 'user-uploads', quota_bytes: 50e9 });++ * const { zones, next_cursor } = await zone.zones.list('prj_01…', { env: 'production' });++ * const usage = await zone.zones.usage(z.id); // Media's live snapshot and this month's metered series++ * await zone.zones.update(z.id, { quota_bytes: 100e9 });++ * await zone.zones.remove(z.id, { recursive: true }); // 202: deleting, with everything in it++ *++ * Who may act on a project: an app of that project holding the route's capability (a sandbox app only in the sandbox),++ * or a person who is a member of it (any role reads; admins and owners change). Anyone else is told the project or++ * zone does not exist. create carries an Idempotency-Key (Zone answers a repeat with the same zone), so it is retried++ * safely. Errors are OpenVibeError: zone.name_taken, zone.name_retired (409), zone.not_empty, zone.held,++ * zone.bad_request, …++ */++const enc = encodeURIComponent;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/; // Zone's own rules++const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;++const ENVS = ['sandbox', 'production'];++++function createZoneClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function') throw new TypeError('createZoneClient: pass an openvibe-sdk/core client');++ const at = { service: 'zone', baseUrl, audience: 'openvibe.zone' };++ const call = (opts) => client.json({ ...at, ...opts });++ const project = (p) => {++ if (!PROJECT_RE.test(String(p || ''))) throw new TypeError('zone: a project is a prj_ id');++ return `/api/v1/projects/${enc(String(p))}/zones`;++ };++ const zonePath = (z, rest = '') => {++ if (!ZONE_RE.test(String(z || ''))) throw new TypeError('zone: a zone is a zon_ id');++ return `/api/v1/zones/${enc(String(z))}${rest}`;++ };++ const env = (e) => {++ if (e != null && !ENVS.includes(e)) throw new TypeError(`zone: env is ${ENVS.join(' or ')}`);++ return e;++ };++++ return {++ zones: {++ /** A new zone (zone.object-zone-create-request@1: env, name, description?, quota_bytes?) → the zone. */++ create: (p, { env: e, name, description, quota_bytes: quota } = {}, { idempotencyKey, signal } = {}) => {++ if (!env(e)) throw new TypeError('zone.zones.create: env is required');++ return call({ method: 'POST', path: project(p), json: { env: e, name, description, quota_bytes: quota }, ...(idempotencyKey ? { idempotencyKey: String(idempotencyKey) } : {}), signal });++ },++ /** The project's zones: { zones, next_cursor }. */++ list: (p, { env: e, include_deleted: includeDeleted, limit, cursor, signal } = {}) => call({ path: project(p), query: { env: env(e), include_deleted: includeDeleted ? 'true' : undefined, limit, cursor }, signal }),++ /** One zone, with its stored bytes and objects. */++ get: (z, { signal } = {}) => call({ path: zonePath(z), signal }),++ /** Change the description or the quota (null for none). */++ update: (z, { description, quota_bytes: quota } = {}, { signal } = {}) => call({ method: 'PATCH', path: zonePath(z), json: { description, quota_bytes: quota }, idempotent: true, signal }),++ /** Delete it; recursive deletes what it holds too (without it, a zone with objects is zone.not_empty). */++ remove: (z, { recursive = false, signal } = {}) => call({ method: 'DELETE', path: zonePath(z), query: { recursive: recursive ? 'true' : undefined }, signal }),++ /** Media's live snapshot and this month's metered series (requests, egress, byte-hours). */++ usage: (z, { signal } = {}) => call({ path: zonePath(z, '/usage'), signal }),++ },++ };++}++++module.exports = { createZoneClient };@@@ -76,7 +76,7 @@ run([for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);}assert.deepEqual(problems, []);-- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './inventory', './limits', './live', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './tips', './usage', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './cache', './commerce', './cost', './db', './delivery', './download', './events', './food', './govern', './identity', './inventory', './limits', './live', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './run', './runtime', './service', './sso', './storage', './telemetry', './testing', './tips', './usage', './valkey', './video', './vip', './work', './zone']);const rel = [...scanned].map((f) => path.relative(ROOT, f));for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);@@@ -66,7 +66,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, 50);++ assert.equal(subpaths.length, 56);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,229 @@++'use strict';++// openvibe-sdk/food, /actor, /run, /video, /work and /zone through a real core client against one local stub: every++// route and method, the token audience, which writes are retried (keyed or idempotent) and which never are, the SSE++// followers (Actor's task events, Run's job stream), and the id checks done before any call.++const assert = require('assert');++const http = require('http');++const { createClient } = require('../src/core');++const { createFoodClient } = require('../src/food');++const { createActorClient } = require('../src/actor');++const { createRunClient } = require('../src/run');++const { createVideoClient } = require('../src/video');++const { createWorkClient } = require('../src/work');++const { createZoneClient } = require('../src/zone');++++const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ids = { plan: `pln_${ULID}`, task: `tsk_${ULID}`, agent: `agt_${ULID}`, mem: `mem_${ULID}`, job: `job_${ULID}`, playlist: `vpl_${ULID}`, app: `wap_${ULID}`, project: `prj_${ULID}`, zone: `zon_${ULID}` };++++(async () => {++ const seen = [];++ const attempts = {};++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ const svc = req.url.split('/')[1];++ const url = req.url.slice(svc.length + 1);++ seen.push({ svc, method: req.method, url, idem: req.headers['idempotency-key'] || null, project: req.headers['x-ov-project'] || null, lastEvent: req.headers['last-event-id'] || null, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ const key = `${req.method} ${req.url} ${body}`; // one 503 per distinct request++ attempts[key] = (attempts[key] || 0) + 1;++ // Every write answers 503 once: a write the client may repeat is retried, one it may not is thrown.++ if (req.method !== 'GET' && attempts[key] === 1 && !/stream\/ticket|\/route$/.test(req.url)) return json(503, { type: 'about:blank', title: 'busy', status: 503 });++ if (/\/events$|\/stream$/.test(url)) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ res.write('id: 3\nevent: state\ndata: {"state":"running"}\n\n');++ res.write(': keep-alive\n\n');++ res.end('id: 4\nevent: end\ndata: {"state":"succeeded"}\n\n');++ return undefined;++ }++ return json(req.method === 'POST' ? 201 : 200, { ok: true });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const audiences = [];++ const client = createClient({++ baseUrls: { food: `${base}/food`, actor: `${base}/actor`, run: `${base}/run`, 'media-hub': `${base}/video`, work: `${base}/work`, zone: `${base}/zone` },++ getToken: ({ audience }) => { audiences.push(audience); return `tok-${audience}`; },++ retries: 2, retryDelayMs: 1, maxRetryDelayMs: 2,++ });++ const since = () => { const n = seen.length; return () => seen.slice(n); };++ const busyOnce = async (fn) => assert.rejects(fn, (e) => e.status === 503, 'a write that is never retried surfaces the 503');++++ try {++ // ── Food ──────────────────────────────────────────────++ const food = createFoodClient(client);++ let mark = since();++ await food.places({ kind: 'foodbank', lat: 45.5, lon: -122.6, radius: 10 });++ await food.foods.list({ group: 'vegetables', q: 'carrot' });++ await food.foods.get('carrots');++ await food.plans.list({ limit: 5 });++ await food.plans.get(ids.plan);++ await food.pantry.get();++ await food.pantry.set(['rice', 'onions']); // PUT: retried++ await busyOnce(() => food.plans.create({ people: 2, days: 7, budget: 80 }));++ await busyOnce(() => food.recipes.suggest({ ask: 'quick', people: 2 }));++ await food.recipes.list();++ assert.deepStrictEqual(mark().map((r) => `${r.method} ${r.url}`), [++ 'GET /api/v1/places?kind=foodbank&lat=45.5&lon=-122.6&radius=10', 'GET /api/v1/foods?group=vegetables&q=carrot', 'GET /api/v1/foods/carrots',++ 'GET /api/v1/plans?limit=5', `GET /api/v1/plans/${ids.plan}`, 'GET /api/v1/pantry', 'PUT /api/v1/pantry', 'PUT /api/v1/pantry',++ 'POST /api/v1/plans', 'POST /api/v1/recipes/suggest', 'GET /api/v1/recipes',++ ]);++ assert.throws(() => food.plans.get('nope'), /pln_/);++ assert.throws(() => food.pantry.set('rice'), /array/);++++ // ── Actor ─────────────────────────────────────────────++ const actor = createActorClient(client);++ mark = since();++ await actor.agents.list();++ await actor.route({ task: 'sum it up', mode: 'cheapest' });++ await actor.tasks.create({ task: 'Summarize', idempotency_key: 'job-0042' }); // keyed in the body: retried++ await busyOnce(() => actor.tasks.create({ task: 'Again, unkeyed' }));++ await actor.tasks.list({ limit: 10 });++ await actor.tasks.get(ids.task);++ await actor.tasks.cancel(ids.task); // retried++ const evs = [];++ for await (const e of actor.tasks.events(ids.task, { lastEventId: 2 })) evs.push(e);++ assert.deepStrictEqual(evs, [{ id: 3, event: 'state', data: { state: 'running' } }, { id: 4, event: 'end', data: { state: 'succeeded' } }]);++ await actor.me.agents.list();++ await busyOnce(() => actor.me.agents.create({ name: 'Brief', instructions: 'x' }));++ await actor.me.agents.get(ids.agent);++ await actor.me.agents.update(ids.agent, { status: 'paused' });++ await actor.me.agents.remove(ids.agent);++ await busyOnce(() => actor.me.agents.run(ids.agent, { input: 'today' }));++ await actor.me.agents.runs(ids.agent, { limit: 3 });++ await actor.me.agents.memory.list(ids.agent);++ await busyOnce(() => actor.me.agents.memory.add(ids.agent, 'I live in Portland'));++ await actor.me.agents.memory.update(ids.agent, ids.mem, 'Portland, Oregon');++ await actor.me.agents.memory.remove(ids.agent, ids.mem);++ await actor.me.agents.memory.clear(ids.agent);++ const a = mark();++ assert.deepStrictEqual(a.map((r) => `${r.method} ${r.url}`), [++ 'GET /api/v1/agents', 'POST /api/v1/route', 'POST /api/v1/tasks', 'POST /api/v1/tasks', 'POST /api/v1/tasks',++ 'GET /api/v1/tasks?limit=10', `GET /api/v1/tasks/${ids.task}`, `POST /api/v1/tasks/${ids.task}/cancel`, `POST /api/v1/tasks/${ids.task}/cancel`,++ `GET /api/v1/tasks/${ids.task}/events`, 'GET /api/v1/me/agents', 'POST /api/v1/me/agents', `GET /api/v1/me/agents/${ids.agent}`,++ `PATCH /api/v1/me/agents/${ids.agent}`, `PATCH /api/v1/me/agents/${ids.agent}`, `DELETE /api/v1/me/agents/${ids.agent}`, `DELETE /api/v1/me/agents/${ids.agent}`,++ `POST /api/v1/me/agents/${ids.agent}/run`, `GET /api/v1/me/agents/${ids.agent}/runs?limit=3`, `GET /api/v1/me/agents/${ids.agent}/memory`,++ `POST /api/v1/me/agents/${ids.agent}/memory`, `PATCH /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`, `PATCH /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`,++ `DELETE /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`, `DELETE /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`,++ `DELETE /api/v1/me/agents/${ids.agent}/memory`, `DELETE /api/v1/me/agents/${ids.agent}/memory`,++ ]);++ assert.strictEqual(a.find((r) => r.url.endsWith('/events')).lastEvent, '2', 'the stream picks up after lastEventId');++ assert.deepStrictEqual(a.find((r) => r.url === `/api/v1/me/agents/${ids.agent}/run`).body, { input: 'today' });++ assert.throws(() => actor.tasks.get('tsk_bad'), /tsk_/);++ assert.throws(() => actor.tasks.create({ task: ' ' }), /instruction/);++++ // ── Run ───────────────────────────────────────────────++ const run = createRunClient(client);++ mark = since();++ const submitted = { class: 'function', artifact: { name: 'thumbnailer', version: '1.2.0' }, limits: { wall_ms: 1000, cpu_ms: 500, mem_bytes: 1e8 } };++ await run.jobs.submit(submitted); // keyed by the core client: retried with the same key++ await run.jobs.submit({ ...submitted, idempotency_key: 'thumb-7' });++ await run.jobs.list({ state: 'running', limit: 5 });++ await run.jobs.get(ids.job);++ await run.jobs.cancel(ids.job);++ await run.jobs.streamTicket(ids.job);++ const jobEvents = [];++ for await (const e of run.jobs.events(ids.job)) jobEvents.push(e.event);++ assert.deepStrictEqual(jobEvents, ['state', 'end']);++ await run.forProject(ids.project).jobs.get(ids.job);++ const r = mark();++ assert.deepStrictEqual(r.map((x) => `${x.method} ${x.url}`), [++ 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'GET /api/v1/jobs?state=running&limit=5', `GET /api/v1/jobs/${ids.job}`,++ `POST /api/v1/jobs/${ids.job}/cancel`, `POST /api/v1/jobs/${ids.job}/cancel`, `POST /api/v1/jobs/${ids.job}/stream/ticket`,++ `GET /api/v1/jobs/${ids.job}/stream`, `GET /api/v1/jobs/${ids.job}`,++ ]);++ assert.ok(r[0].idem && r[0].idem === r[1].idem, 'the retried submit repeats its Idempotency-Key');++ assert.deepStrictEqual([r[2].idem, r[3].idem], ['thumb-7', 'thumb-7'], "the body's idempotency_key is the header");++ assert.strictEqual(r[10].project, ids.project, 'forProject names the project');++ assert.strictEqual(r[5].project, null);++ assert.throws(() => run.jobs.list({ state: 'done' }), /state is one of/);++ assert.throws(() => run.jobs.submit({ class: 'function' }), /limits/);++ assert.throws(() => run.forProject('nope'), /prj_/);++++ // ── Video ─────────────────────────────────────────────++ const video = createVideoClient(client);++ mark = since();++ await video.playlists.list();++ await busyOnce(() => video.playlists.create({ title: 'Best of', visibility: 'public' }));++ await video.playlists.get(ids.playlist);++ await video.playlists.update(ids.playlist, { title: 'Best of October' });++ await busyOnce(() => video.playlists.items.add(ids.playlist, { kind: 'vod', id: '5579' }));++ await video.playlists.items.reorder(ids.playlist, [{ kind: 'clip', id: 12 }, { kind: 'vod', id: 5579 }]);++ await video.playlists.items.remove(ids.playlist, { kind: 'clip', id: 12 });++ await video.playlists.remove(ids.playlist);++ await video.users.playlists('goosely', { limit: 10 });++ const v = mark();++ assert.deepStrictEqual(v.map((x) => `${x.method} ${x.url}`), [++ 'GET /api/v1/video/playlists', 'POST /api/v1/video/playlists', `GET /api/v1/video/playlists/${ids.playlist}`,++ `PATCH /api/v1/video/playlists/${ids.playlist}`, `PATCH /api/v1/video/playlists/${ids.playlist}`, `POST /api/v1/video/playlists/${ids.playlist}/items`,++ `PATCH /api/v1/video/playlists/${ids.playlist}/items`, `PATCH /api/v1/video/playlists/${ids.playlist}/items`,++ `DELETE /api/v1/video/playlists/${ids.playlist}/items/clip/12`, `DELETE /api/v1/video/playlists/${ids.playlist}/items/clip/12`,++ `DELETE /api/v1/video/playlists/${ids.playlist}`, `DELETE /api/v1/video/playlists/${ids.playlist}`, 'GET /api/v1/video/users/goosely/playlists?limit=10',++ ]);++ assert.deepStrictEqual(v[5].body, { kind: 'vod', id: 5579 });++ assert.deepStrictEqual(v[6].body, { items: [{ kind: 'clip', id: 12 }, { kind: 'vod', id: 5579 }] });++ assert.throws(() => video.playlists.items.add(ids.playlist, { kind: 'paste', id: 1 }), /'vod' or 'clip'/);++ assert.throws(() => video.playlists.get('pl_1'), /vpl_/);++++ // ── Work ──────────────────────────────────────────────++ const work = createWorkClient(client);++ mark = since();++ await work.jobs.search({ q: 'node', remote: true, limit: 20 });++ await work.jobs.get('rok-123');++ await work.sources();++ await work.savedSearches.list();++ await busyOnce(() => work.savedSearches.create({ q: 'robotics', remote: false }));++ await work.savedSearches.remove('ssc_1');++ await work.applications.list({ status: 'applied' });++ await work.applications.create({ listing_id: 'rok-123', status: 'saved' }); // a listing is tracked once: retried++ await busyOnce(() => work.applications.create({ title: 'Robot wrangler', company: 'Acme' }));++ await work.applications.get(ids.app);++ await work.applications.update(ids.app, { status: 'interviewing' });++ await work.applications.remove(ids.app);++ await busyOnce(() => work.applications.assist(ids.app, { ask: 'a cover letter' }));++ const w = mark();++ assert.deepStrictEqual(w.map((x) => `${x.method} ${x.url}`), [++ 'GET /api/v1/jobs?q=node&remote=true&limit=20', 'GET /api/v1/jobs/rok-123', 'GET /api/v1/sources', 'GET /api/v1/saved-searches',++ 'POST /api/v1/saved-searches', 'DELETE /api/v1/saved-searches/ssc_1', 'DELETE /api/v1/saved-searches/ssc_1', 'GET /api/v1/me/applications?status=applied',++ 'POST /api/v1/me/applications', 'POST /api/v1/me/applications', 'POST /api/v1/me/applications', `GET /api/v1/me/applications/${ids.app}`,++ `PATCH /api/v1/me/applications/${ids.app}`, `PATCH /api/v1/me/applications/${ids.app}`, `DELETE /api/v1/me/applications/${ids.app}`,++ `DELETE /api/v1/me/applications/${ids.app}`, `POST /api/v1/me/applications/${ids.app}/assist`,++ ]);++ assert.deepStrictEqual(w[4].body, { q: 'robotics', remote: 'false' });++ assert.throws(() => work.applications.update(ids.app, { status: 'hired' }), /status is one of/);++ assert.throws(() => work.applications.get('app_1'), /wap_/);++++ // ── Zone ──────────────────────────────────────────────++ const zone = createZoneClient(client);++ mark = since();++ await zone.zones.create(ids.project, { env: 'production', name: 'user-uploads', quota_bytes: 5e10 }, { idempotencyKey: 'zone-create-1' });++ await zone.zones.list(ids.project, { env: 'sandbox', include_deleted: true, limit: 5 });++ await zone.zones.get(ids.zone);++ await zone.zones.update(ids.zone, { quota_bytes: null });++ await zone.zones.remove(ids.zone, { recursive: true });++ await zone.zones.usage(ids.zone);++ const z = mark();++ assert.deepStrictEqual(z.map((x) => `${x.method} ${x.url}`), [++ `POST /api/v1/projects/${ids.project}/zones`, `POST /api/v1/projects/${ids.project}/zones`,++ `GET /api/v1/projects/${ids.project}/zones?env=sandbox&include_deleted=true&limit=5`, `GET /api/v1/zones/${ids.zone}`,++ `PATCH /api/v1/zones/${ids.zone}`, `PATCH /api/v1/zones/${ids.zone}`, `DELETE /api/v1/zones/${ids.zone}?recursive=true`,++ `DELETE /api/v1/zones/${ids.zone}?recursive=true`, `GET /api/v1/zones/${ids.zone}/usage`,++ ]);++ assert.deepStrictEqual([z[0].idem, z[1].idem], ['zone-create-1', 'zone-create-1']);++ assert.deepStrictEqual(z[4].body, { quota_bytes: null }, 'null clears the quota');++ assert.throws(() => zone.zones.create(ids.project, { name: 'x' }), /env is required/);++ assert.throws(() => zone.zones.get('zone_1'), /zon_/);++++ // Each product's token is for its own audience (Video's is the media hub's, openvibe.download).++ assert.deepStrictEqual([...new Set(audiences)].sort(), ['openvibe.actor', 'openvibe.download', 'openvibe.food', 'openvibe.run', 'openvibe.work', 'openvibe.zone']);++ assert.ok(seen.every((x) => x.auth === `Bearer tok-${{ food: 'openvibe.food', actor: 'openvibe.actor', run: 'openvibe.run', video: 'openvibe.download', work: 'openvibe.work', zone: 'openvibe.zone' }[x.svc]}`));++++ // Each client needs a core client.++ for (const make of [createFoodClient, createActorClient, createRunClient, createVideoClient, createWorkClient, createZoneClient]) assert.throws(() => make({}), /core client/);++ console.log('product modules (food, actor, run, video, work, zone): all checks passed');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,39 @@++/** openvibe-sdk/actor: OpenVibe.Actor — tasks for AI agent systems, and a person's own agents and what they remember. */++type Opts = { signal?: AbortSignal };++type ActorJson = Record<string, any>;++export type ActorMode = 'cheapest' | 'balanced' | 'best' | 'fastest' | 'private';++export interface TaskRequest {++ task: string; mode?: ActorMode; budget?: { per_task_usd?: number; per_day_usd?: number }; agent?: string; idempotency_key?: string;++ webhooks?: Array<{ url: string; events?: string[]; secret_ref?: string }>; [k: string]: unknown;++}++export interface TaskEvent { id: number | null; event: string; data: any }++export interface ActorClient {++ agents: { list(opts?: Opts): Promise<{ agents: ActorJson[] }> };++ route(req: { task: string; mode?: ActorMode; agent?: string }, opts?: Opts): Promise<ActorJson>;++ tasks: {++ create(req: TaskRequest, opts?: Opts): Promise<ActorJson & { id: string; webhook_secret?: string }>;++ list(opts?: Opts & { limit?: number; before?: string }): Promise<{ tasks: ActorJson[]; next: string | null }>;++ get(id: string, opts?: Opts): Promise<ActorJson>;++ cancel(id: string, opts?: Opts): Promise<ActorJson>;++ events(id: string, opts?: Opts & { lastEventId?: number }): AsyncGenerator<TaskEvent, void, unknown>;++ };++ me: {++ agents: {++ list(opts?: Opts): Promise<{ agents: ActorJson[] }>;++ create(agent: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;++ get(id: string, opts?: Opts): Promise<ActorJson>;++ update(id: string, patch: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;++ remove(id: string, opts?: Opts): Promise<ActorJson>;++ run(id: string, req?: { input?: string }, opts?: Opts): Promise<ActorJson>;++ runs(id: string, opts?: Opts & { limit?: number; before?: string }): Promise<{ tasks: ActorJson[]; next: string | null }>;++ memory: {++ list(id: string, opts?: Opts): Promise<{ memory: ActorJson[] }>;++ add(id: string, text: string, opts?: Opts): Promise<ActorJson>;++ update(id: string, note: string, text: string, opts?: Opts): Promise<ActorJson>;++ remove(id: string, note: string, opts?: Opts): Promise<ActorJson>;++ clear(id: string, opts?: Opts): Promise<ActorJson>;++ };++ };++ };++}++export function createActorClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): ActorClient;nnew file mode 100644@@@ -0,0 +1,23 @@++/** openvibe-sdk/food: OpenVibe.Food — places to get food, the food list with prices, meal plans, a pantry and recipe ideas. */++type Opts = { signal?: AbortSignal };++type FoodJson = Record<string, any>;++export interface PantryResult {++ items: string[]; foods: FoodJson[];++ suggestions: { can_make: Array<{ slug: string; name: string; serves: number; minutes: number }>; almost: Array<{ slug: string; name: string; serves: number; minutes: number; missing: string[] }> };++ note: string;++}++export interface FoodClient {++ places(opts?: Opts & { kind?: string; q?: string; lat?: number; lon?: number; radius?: number; limit?: number }): Promise<FoodJson>;++ foods: {++ list(opts?: Opts & { group?: string; q?: string }): Promise<{ foods: FoodJson[]; count: number; groups: FoodJson[]; source: FoodJson }>;++ get(slug: string, opts?: Opts): Promise<{ food: FoodJson; source: FoodJson }>;++ };++ plans: {++ create(req: { people: number; days: number; budget?: number | null; title?: string | null }, opts?: Opts): Promise<FoodJson>;++ list(opts?: Opts & { limit?: number }): Promise<{ plans: FoodJson[] }>;++ get(id: string, opts?: Opts): Promise<FoodJson>;++ };++ pantry: { get(opts?: Opts): Promise<PantryResult>; set(items: string[], opts?: Opts): Promise<PantryResult> };++ recipes: { suggest(req?: { ask?: string; people?: number }, opts?: Opts): Promise<FoodJson>; list(opts?: Opts): Promise<{ recipes: FoodJson[] }> };++}++export function createFoodClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): FoodClient;@@@ -20,6 +20,12 @@ export * as quest from './quest';export * as live from './live';export * as inventory from './inventory';export * as tips from './tips';++export * as food from './food';++export * as actor from './actor';++export * as run from './run';++export * as video from './video';++export * as work from './work';++export * as zone from './zone';export * as play from './play';export * as download from './download';export * as openre from './openre';nnew file mode 100644@@@ -0,0 +1,22 @@++/** openvibe-sdk/run: OpenVibe.Run — jobs on worker nodes: submit, follow, cancel. */++type Opts = { signal?: AbortSignal };++type RunJson = Record<string, any>;++export type RunJobState = 'queued' | 'placed' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired';++export const RUN_JOB_STATES: RunJobState[];++export interface JobRequest {++ class: string; artifact?: { name: string; version: string }; args?: Record<string, unknown>; inputs?: unknown[];++ limits: { wall_ms: number; cpu_ms: number; mem_bytes: number }; ttl_ms?: number; egress?: unknown; requirements?: unknown; idempotency_key?: string;++}++export interface JobEvent { id: number | null; event: string; data: any }++export interface RunClient {++ jobs: {++ submit(req: JobRequest, opts?: Opts & { idempotencyKey?: string }): Promise<{ created: boolean; job: RunJson }>;++ list(opts?: Opts & { state?: RunJobState; limit?: number; cursor?: string }): Promise<{ jobs: RunJson[]; next_cursor: string | null }>;++ get(id: string, opts?: Opts): Promise<RunJson>;++ cancel(id: string, opts?: Opts): Promise<RunJson>;++ streamTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string; expires_in: number; stream_url: string; job_id: string }>;++ events(id: string, opts?: Opts & { lastEventId?: number }): AsyncGenerator<JobEvent, void, unknown>;++ };++ forProject(project: string): RunClient;++}++export function createRunClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string; project?: string }): RunClient;nnew file mode 100644@@@ -0,0 +1,22 @@++/** openvibe-sdk/video: OpenVibe.Video's playlists of recordings and clips. */++type Opts = { signal?: AbortSignal };++export type PlaylistVisibility = 'public' | 'unlisted' | 'private';++export interface PlaylistItemRef { kind: 'vod' | 'clip'; id: number }++export interface Playlist { id: string; title: string; description: string | null; visibility: PlaylistVisibility; item_count: number; created_at: string; updated_at: string; owner?: { kind: string; username?: string | null } }++export interface PlaylistItem extends PlaylistItemRef { position: number; added_at: string }++export interface VideoClient {++ playlists: {++ list(opts?: Opts): Promise<{ playlists: Playlist[] }>;++ create(req: { title: string; description?: string; visibility?: PlaylistVisibility }, opts?: Opts): Promise<{ playlist: Playlist }>;++ get(id: string, opts?: Opts): Promise<{ playlist: Playlist; items: PlaylistItem[] }>;++ update(id: string, patch: { title?: string; description?: string | null; visibility?: PlaylistVisibility }, opts?: Opts): Promise<{ playlist: Playlist }>;++ remove(id: string, opts?: Opts): Promise<unknown>;++ items: {++ add(id: string, item: PlaylistItemRef, opts?: Opts): Promise<Record<string, any>>;++ reorder(id: string, items: PlaylistItemRef[], opts?: Opts): Promise<{ items: PlaylistItem[] }>;++ remove(id: string, item: PlaylistItemRef, opts?: Opts): Promise<unknown>;++ };++ };++ users: { playlists(username: string, opts?: Opts & { limit?: number; cursor?: string }): Promise<{ playlists: Playlist[]; next_cursor: string | null }> };++}++export function createVideoClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): VideoClient;nnew file mode 100644@@@ -0,0 +1,30 @@++/** openvibe-sdk/work: OpenVibe.Work — the job board, its sources, saved searches and tracked applications. */++type Opts = { signal?: AbortSignal };++type WorkJson = Record<string, any>;++export type ApplicationStatus = 'saved' | 'applied' | 'interviewing' | 'offer' | 'rejected' | 'withdrawn';++export const WORK_APPLICATION_STATUSES: ApplicationStatus[];++export interface ApplicationFields {++ listing_id?: string; title?: string; company?: string; url?: string; status?: ApplicationStatus; notes?: string;++ applied_at?: string | null; next_step?: string; next_step_at?: string | null;++}++export interface WorkClient {++ jobs: {++ search(opts?: Opts & { q?: string; remote?: boolean; type?: string; location?: string; limit?: number; cursor?: string }): Promise<{ jobs: WorkJson[]; next: string | null; count: number; filters: WorkJson; facets: WorkJson; sources: WorkJson[] }>;++ get(id: string, opts?: Opts): Promise<WorkJson>;++ };++ sources(opts?: Opts): Promise<{ sources: WorkJson[]; cadence_hours: number; retention_days: number }>;++ savedSearches: {++ list(opts?: Opts): Promise<{ saved_searches: WorkJson[] }>;++ create(req: { q?: string; remote?: boolean; type?: string; location?: string }, opts?: Opts): Promise<WorkJson>;++ remove(id: string, opts?: Opts): Promise<unknown>;++ };++ applications: {++ list(opts?: Opts & { status?: ApplicationStatus; limit?: number; cursor?: string }): Promise<{ applications: WorkJson[]; next: string | null }>;++ create(fields: ApplicationFields, opts?: Opts): Promise<WorkJson>;++ get(id: string, opts?: Opts): Promise<WorkJson>;++ update(id: string, patch: ApplicationFields, opts?: Opts): Promise<WorkJson>;++ remove(id: string, opts?: Opts): Promise<unknown>;++ assist(id: string, req?: { ask?: string }, opts?: Opts): Promise<{ task_id: string; task_url: string }>;++ };++}++export function createWorkClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): WorkClient;nnew file mode 100644@@@ -0,0 +1,15 @@++/** openvibe-sdk/zone: OpenVibe.Zone — a project's object zones and their usage. */++type Opts = { signal?: AbortSignal };++type ZoneJson = Record<string, any>;++export type ZoneEnv = 'sandbox' | 'production';++export interface ZoneClient {++ zones: {++ create(project: string, req: { env: ZoneEnv; name: string; description?: string | null; quota_bytes?: number | null }, opts?: Opts & { idempotencyKey?: string }): Promise<ZoneJson>;++ list(project: string, opts?: Opts & { env?: ZoneEnv; include_deleted?: boolean; limit?: number; cursor?: string }): Promise<{ zones: ZoneJson[]; next_cursor: string | null }>;++ get(zone: string, opts?: Opts): Promise<ZoneJson>;++ update(zone: string, patch: { description?: string | null; quota_bytes?: number | null }, opts?: Opts): Promise<ZoneJson>;++ remove(zone: string, opts?: Opts & { recursive?: boolean }): Promise<ZoneJson>;++ usage(zone: string, opts?: Opts): Promise<ZoneJson>;++ };++}++export function createZoneClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): ZoneClient;
@@ -3,6 +3,25 @@
@@ -3,6 +3,25 @@
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.55.0 (2026-10-20)
+
+- Six more products through the SDK, each a server module over the routes its capabilities name in Contracts:
+ - **`openvibe-sdk/food`** (`createFoodClient`): places to get food, the food list with prices, meal plans, a
+ person's pantry with its "what can I make" matches, and AI recipe ideas.
+ - **`openvibe-sdk/actor`** (`createActorClient`): tasks for AI agent systems (create, list, get, cancel, and
+ `tasks.events`, the SSE stream until the task ends), the public agent list and dry-run routing, and a person's
+ own agents with their runs and memory.
+ - **`openvibe-sdk/run`** (`createRunClient`): jobs on worker nodes (submit with an Idempotency-Key, list, get,
+ cancel, `jobs.events` until `end`, a browser stream ticket), `forProject` for X-OV-Project.
+ - **`openvibe-sdk/video`** (`createVideoClient`): OpenVibe.Video's playlists of recordings and clips, and a
+ person's public playlists.
+ - **`openvibe-sdk/work`** (`createWorkClient`): the job board's search and sources, saved searches, tracked
+ applications and the agent's help with one.
+ - **`openvibe-sdk/zone`** (`createZoneClient`): a project's object zones and their usage.
+- A write that makes something new each time (a plan, recipe ideas, an agent, a run, a memory note, a playlist or an
+ item in it, a saved search, an application without a listing) is never retried on its own. Keyed writes (Run's
+ submit, Zone's create, an Actor task with an idempotency_key, an application for a listing) and changes are.
+
## 0.54.0 (2026-10-20)
## 0.54.0 (2026-10-20)
- **`openvibe-sdk/tips`**: OpenVibe.Tips through the SDK (it had 19 capabilities and no module). `createTipsClient(client)`
- **`openvibe-sdk/tips`**: OpenVibe.Tips through the SDK (it had 19 capabilities and no module). `createTipsClient(client)`
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.54.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.55.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.
**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.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.54.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.55.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).
@@ -190,6 +190,12 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -190,6 +190,12 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried |
| `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried |
| `openvibe-sdk/inventory` | server | `createInventoryClient(client)`: OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: `kinds.list/get`, `definitions.list({kind, issuer})/get`, `people.items(subject)`, `people.equipped(subject)`, `equipped([subjects])` (≤ 100, for chat renders). The person's own: `me.items()`, `me.equip({kind, slot, instance_id | null})`. An issuer's: `definitions.create/update`, `grants.create({definition_id, subject, idempotency_key})` → `{ instance, created }` (retry-safe on the key), `instances.consume/revoke`. `forPerson(usr_…)` sends `X-OV-Subject` for a service acting for a person. Audience `openvibe.inventory` |
| `openvibe-sdk/inventory` | server | `createInventoryClient(client)`: OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: `kinds.list/get`, `definitions.list({kind, issuer})/get`, `people.items(subject)`, `people.equipped(subject)`, `equipped([subjects])` (≤ 100, for chat renders). The person's own: `me.items()`, `me.equip({kind, slot, instance_id | null})`. An issuer's: `definitions.create/update`, `grants.create({definition_id, subject, idempotency_key})` → `{ instance, created }` (retry-safe on the key), `instances.consume/revoke`. `forPerson(usr_…)` sends `X-OV-Subject` for a service acting for a person. Audience `openvibe.inventory` |
| `openvibe-sdk/tips` | server | `createTipsClient(client)`: OpenVibe.Tips' `/api/v1`. A creator's page: `profiles.get/update/totals/sounds/supporters`. Tipping: `tip({creator, amount, message?, goal_id?, pay_with})` → `{ interaction, checkout_url, checkout_ref }`, `send.message/tts/media/sound`. Receipts: `interactions.get/list/recordExternal/hide/restore`. `goals.list/get/create/update/close`, `moderation.queue/log/moderators.*`, `overlays.tokens.*/configs.*/simulate`, `me.export/erase`. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience `openvibe.tips` |
| `openvibe-sdk/tips` | server | `createTipsClient(client)`: OpenVibe.Tips' `/api/v1`. A creator's page: `profiles.get/update/totals/sounds/supporters`. Tipping: `tip({creator, amount, message?, goal_id?, pay_with})` → `{ interaction, checkout_url, checkout_ref }`, `send.message/tts/media/sound`. Receipts: `interactions.get/list/recordExternal/hide/restore`. `goals.list/get/create/update/close`, `moderation.queue/log/moderators.*`, `overlays.tokens.*/configs.*/simulate`, `me.export/erase`. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience `openvibe.tips` |
+| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |
+| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |
+| `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` |
+| `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) |
+| `openvibe-sdk/work` | server | `createWorkClient(client)`: OpenVibe.Work. Public: `jobs.search({q, remote, type, location, cursor})`, `jobs.get`, `sources()`. A person's: `savedSearches.list/create/remove`, `applications.list/create/get/update/remove` (also an app with `work.application.manage`; tracking a listing twice answers the first), `applications.assist(id, {ask})` (their Actor agent helps). Audience `openvibe.work` |
+| `openvibe-sdk/zone` | server | `createZoneClient(client)`: OpenVibe.Zone, a project's object zones in Media. `zones.create(prj_…, {env, name, description, quota_bytes})` (Idempotency-Key), `zones.list(prj_…, {env, include_deleted})`, `zones.get/update/remove(zon_…, {recursive})`, `zones.usage(zon_…)`. Project members and the project's apps only. Audience `openvibe.zone` |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
@@ -470,7 +476,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr
@@ -470,7 +476,7 @@ Install the drivers the service uses: `pg` and `iovalkey` (production), `@electr
- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.
- **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects.
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
-- **Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.
+- **Billing and Games** from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Products whose capabilities are all first-party (Blog, Coupons, Deals, News, Reviews, Space, Trade, Watch, Wiki and others) have no module yet: there is nothing an outside app may call. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.
- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.
- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
- **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not.
- **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.54.0",
+ "version": "0.55.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",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.54.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.55.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, 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, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,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.54.0';
+const SDK_VERSION = '0.55.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/actor.js. Do not edit.
+import cjs from '../src/actor.js';
+
+export const {
+ createActorClient,
+} = 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/food.js. Do not edit.
+import cjs from '../src/food.js';
+
+export const {
+ createFoodClient,
+} = cjs;
+export default cjs;
@@ -8,7 +8,8 @@ export const {
@@ -8,7 +8,8 @@ export const {
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
community, bot, search, chat, ai, jobs,
tools, projects, resources, vip, quest, live,
tools, projects, resources, vip, quest, live,
- inventory, tips, play, download, openre, geo,
+ inventory, tips, food, actor, run, video,
+ work, zone, play, download, openre, geo,
usage, commerce, telemetry,
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/run.js. Do not edit.
+import cjs from '../src/run.js';
+
+export const {
+ createRunClient, RUN_JOB_STATES,
+} = 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/video.js. Do not edit.
+import cjs from '../src/video.js';
+
+export const {
+ createVideoClient,
+} = 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/work.js. Do not edit.
+import cjs from '../src/work.js';
+
+export const {
+ createWorkClient, WORK_APPLICATION_STATUSES,
+} = 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/zone.js. Do not edit.
+import cjs from '../src/zone.js';
+
+export const {
+ createZoneClient,
+} = cjs;
+export default cjs;
@@ -29,6 +29,12 @@ module.exports = {
@@ -29,6 +29,12 @@ module.exports = {
live: require('./src/live'),
live: require('./src/live'),
inventory: require('./src/inventory'),
inventory: require('./src/inventory'),
tips: require('./src/tips'),
tips: require('./src/tips'),
+ food: require('./src/food'),
+ actor: require('./src/actor'),
+ run: require('./src/run'),
+ video: require('./src/video'),
+ work: require('./src/work'),
+ zone: require('./src/zone'),
play: require('./src/play'),
play: require('./src/play'),
download: require('./src/download'),
download: require('./src/download'),
openre: require('./src/openre'),
openre: require('./src/openre'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.54.0",
+ "version": "0.55.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.54.0",
+ "version": "0.55.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.54.0",
+ "version": "0.55.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",
@@ -272,6 +272,42 @@
@@ -272,6 +272,42 @@
"import": "./esm/tips.mjs",
"import": "./esm/tips.mjs",
"default": "./src/tips.js"
"default": "./src/tips.js"
},
},
+ "./food": {
+ "types": "./types/food.d.ts",
+ "browser": null,
+ "import": "./esm/food.mjs",
+ "default": "./src/food.js"
+ },
+ "./actor": {
+ "types": "./types/actor.d.ts",
+ "browser": null,
+ "import": "./esm/actor.mjs",
+ "default": "./src/actor.js"
+ },
+ "./run": {
+ "types": "./types/run.d.ts",
+ "browser": null,
+ "import": "./esm/run.mjs",
+ "default": "./src/run.js"
+ },
+ "./video": {
+ "types": "./types/video.d.ts",
+ "browser": null,
+ "import": "./esm/video.mjs",
+ "default": "./src/video.js"
+ },
+ "./work": {
+ "types": "./types/work.d.ts",
+ "browser": null,
+ "import": "./esm/work.mjs",
+ "default": "./src/work.js"
+ },
+ "./zone": {
+ "types": "./types/zone.d.ts",
+ "browser": null,
+ "import": "./esm/zone.mjs",
+ "default": "./src/zone.js"
+ },
"./vip": {
"./vip": {
"types": "./types/vip.d.ts",
"types": "./types/vip.d.ts",
"browser": null,
"browser": null,
@@ -58,6 +58,12 @@ const ENTRIES = {
@@ -58,6 +58,12 @@ const ENTRIES = {
'live.mjs': '../src/live.js',
'live.mjs': '../src/live.js',
'inventory.mjs': '../src/inventory.js',
'inventory.mjs': '../src/inventory.js',
'tips.mjs': '../src/tips.js',
'tips.mjs': '../src/tips.js',
+ 'food.mjs': '../src/food.js',
+ 'actor.mjs': '../src/actor.js',
+ 'run.mjs': '../src/run.js',
+ 'video.mjs': '../src/video.js',
+ 'work.mjs': '../src/work.js',
+ 'zone.mjs': '../src/zone.js',
'vip.mjs': '../src/vip.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'frame.mjs': '../src/frame.js',
'service.mjs': '../src/service/index.js',
'service.mjs': '../src/service/index.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * openvibe-sdk/actor — OpenVibe.Actor for apps: hand a task to an AI agent system and follow it, and keep a person's
+ * own agents (standing instructions, a schedule, a budget per run, what they remember). Routes at
+ * https://openvibe.actor/api/v1; tokens for audience openvibe.actor.
+ *
+ * const actor = createActorClient(createClient({ tokenProvider }));
+ * const { agents } = await actor.agents.list(); // the agent systems (public)
+ * const plan = await actor.route({ task: 'Summarize this repo', mode: 'cheapest' }); // dry run: which agent, what cost
+ * const task = await actor.tasks.create({ task: 'Summarize …', idempotency_key: 'job-42' });
+ * for await (const e of actor.tasks.events(task.id)) console.log(e.event, e.data); // until the task ends
+ * const mine = await actor.me.agents.create({ name: 'Morning brief', instructions: '…' });
+ * await actor.me.agents.run(mine.id, { input: 'today' });
+ * await actor.me.agents.memory.add(mine.id, 'I live in Portland');
+ *
+ * A task is its requester's own (actor.task-create-request@1: task, mode, budget, agent, idempotency_key, webhooks).
+ * When it names webhooks, the answer that creates it carries webhook_secret, once. tasks.create is retried only when
+ * the body has an idempotency_key (Actor answers a repeat with the first task); the other writes are never retried on
+ * their own. A person's agents need the person's own token (an app's or another person's is 404).
+ * Errors are OpenVibeError: actor.task.invalid, actor.budget.over_tier, actor.idempotency.conflict (409),
+ * actor.webhook.refused, actor.agent.invalid, actor.agent.limit (429), actor.agent.not_found, actor.task.not_found, …
+ */
+const { parseSSE } = require('./realtime');
+
+const enc = encodeURIComponent;
+const ID_RE = { // Actor's own rules
+ task: /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/,
+ agent: /^agt_[0-9A-HJKMNP-TV-Z]{26}$/,
+ memory: /^mem_[0-9A-HJKMNP-TV-Z]{26}$/,
+};
+
+function createActorClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createActorClient: pass an openvibe-sdk/core client');
+ const at = { service: 'actor', baseUrl, audience: 'openvibe.actor' };
+ const call = (opts) => client.json({ ...at, ...opts });
+ const once = (opts) => call({ ...opts, idempotencyKey: false });
+ const id = (kind, v) => {
+ if (!ID_RE[kind].test(String(v || ''))) throw new TypeError(`actor: a${kind === 'agent' ? 'n' : ''} ${kind} id is ${ID_RE[kind].source.slice(1, 5)}…`);
+ return enc(String(v));
+ };
+ const agentPath = (agent, rest = '') => `/api/v1/me/agents/${id('agent', agent)}${rest}`;
+
+ /**
+ * A task's events as they happen (SSE: { id, event, data }), the backlog after lastEventId first; it ends when
+ * Actor closes the stream after the task's last event. Pass the last id back as lastEventId to pick up again.
+ */
+ async function* events(taskId, { lastEventId = 0, signal } = {}) {
+ const res = await client.request({
+ ...at, path: `/api/v1/tasks/${id('task', taskId)}/events`, responseType: 'response', retries: 0, signal,
+ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(Number(lastEventId) || 0) },
+ });
+ for await (const msg of parseSSE(res.data.body)) {
+ let data = null;
+ try { data = JSON.parse(msg.data); } catch { data = msg.data; }
+ const n = Number(msg.id);
+ yield { id: Number.isFinite(n) && msg.id !== undefined && msg.id !== '' ? n : null, event: msg.event, data };
+ }
+ }
+
+ return {
+ agents: {
+ /** The agent systems a task can go to, with what they are good at and cost (public). */
+ list: ({ signal } = {}) => call({ path: '/api/v1/agents', signal }),
+ },
+ /** A dry run (public): the task's class and the agent and budget Actor would pick, nothing started. */
+ route: ({ task, mode, agent } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/route', json: { task, mode, agent }, idempotent: true, signal }),
+ tasks: {
+ /** Start a task (201, or 200 for an idempotent repeat) → the task (platform.task@1). */
+ create: (body, { signal } = {}) => {
+ if (!body || typeof body.task !== 'string' || !body.task.trim()) throw new TypeError('actor.tasks.create: task is the instruction in plain words');
+ return body.idempotency_key ? call({ method: 'POST', path: '/api/v1/tasks', json: body, idempotent: true, signal }) : once({ method: 'POST', path: '/api/v1/tasks', json: body, signal });
+ },
+ list: ({ limit, before, signal } = {}) => call({ path: '/api/v1/tasks', query: { limit, before }, signal }),
+ get: (taskId, { signal } = {}) => call({ path: `/api/v1/tasks/${id('task', taskId)}`, signal }),
+ /** Ask a task to stop (202 while it winds down, 200 once ended); asking twice is harmless. */
+ cancel: (taskId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/tasks/${id('task', taskId)}/cancel`, idempotent: true, signal }),
+ events,
+ },
+ me: {
+ agents: {
+ list: ({ signal } = {}) => call({ path: '/api/v1/me/agents', signal }),
+ /** A personal agent: name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, … */
+ create: (agent, { signal } = {}) => once({ method: 'POST', path: '/api/v1/me/agents', json: agent, signal }),
+ get: (agent, { signal } = {}) => call({ path: agentPath(agent), signal }),
+ update: (agent, patch, { signal } = {}) => call({ method: 'PATCH', path: agentPath(agent), json: patch, idempotent: true, signal }),
+ remove: (agent, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent), signal }),
+ /** Run it now with an input → the task it started (follow it with tasks.events). */
+ run: (agent, { input } = {}, { signal } = {}) => once({ method: 'POST', path: agentPath(agent, '/run'), json: { input: input == null ? '' : String(input) }, signal }),
+ runs: (agent, { limit, before, signal } = {}) => call({ path: agentPath(agent, '/runs'), query: { limit, before }, signal }),
+ memory: {
+ list: (agent, { signal } = {}) => call({ path: agentPath(agent, '/memory'), signal }),
+ /** Something for the agent to remember (1–1000 characters). */
+ add: (agent, text, { signal } = {}) => once({ method: 'POST', path: agentPath(agent, '/memory'), json: { text }, signal }),
+ update: (agent, note, text, { signal } = {}) => call({ method: 'PATCH', path: agentPath(agent, `/memory/${id('memory', note)}`), json: { text }, idempotent: true, signal }),
+ remove: (agent, note, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent, `/memory/${id('memory', note)}`), signal }),
+ /** Forget everything it remembers. */
+ clear: (agent, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent, '/memory'), signal }),
+ },
+ },
+ },
+ };
+}
+
+module.exports = { createActorClient };
@@ -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.54.0';
+const SDK_VERSION = '0.55.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * openvibe-sdk/food — OpenVibe.Food for apps: places to get food near you (food banks, pantries, free meals; from
+ * OpenStreetMap), the food list with store prices, meal plans, a person's pantry with "what can I make", and recipe
+ * ideas from the network's AI. Routes at https://openvibe.food/api/v1; tokens for audience openvibe.food.
+ *
+ * const food = createFoodClient(createClient({ tokenProvider }));
+ * const near = await food.places({ kind: 'foodbank', lat: 45.52, lon: -122.68, radius: 10 }); // public
+ * const { foods } = await food.foods.list({ group: 'vegetables', q: 'carrot' }); // public
+ * const plan = await food.plans.create({ people: 2, days: 7, budget: 80, title: 'This week' }); // food.plan.write
+ * await food.pantry.set(['rice', 'black-beans', 'onions']); // food.pantry.write
+ * const { suggestions } = await food.pantry.get(); // { can_make, almost }
+ * const ideas = await food.recipes.suggest({ ask: 'something quick', people: 2 }); // 10 a day
+ *
+ * A plan, a pantry and recipe ideas are the caller's own: a person's token, or an app holding the capability. A plan
+ * is deterministic (all six MyPlate groups, a shopping list, the cheapest store per line, a budget as a hard cap); the
+ * pantry's suggestions are matches against basic recipes, not dietary advice. Creating a plan or asking for recipe
+ * ideas makes a new one each time, so neither is retried on its own; setting the pantry is a PUT and is.
+ * Errors are OpenVibeError: food.plan.invalid, food.pantry.invalid, food.pantry.unknown_item, food.pantry_empty,
+ * food.recipes_daily_limit (429), food.ai_off (503), food.plan.not_found, …
+ */
+const enc = encodeURIComponent;
+const PLAN_ID_RE = /^pln_[0-9A-HJKMNP-TV-Z]{26}$/; // Food's own rule
+
+function createFoodClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createFoodClient: pass an openvibe-sdk/core client');
+ const at = { service: 'food', baseUrl, audience: 'openvibe.food' };
+ const call = (opts) => client.json({ ...at, ...opts });
+ const planId = (id) => {
+ if (!PLAN_ID_RE.test(String(id || ''))) throw new TypeError('food: a plan is a pln_ id');
+ return enc(String(id));
+ };
+
+ return {
+ /** Places to get food (public): kind foodbank (default) or another kind Food lists, near lat/lon or a place q. */
+ places: ({ kind, q, lat, lon, radius, limit, signal } = {}) => call({ path: '/api/v1/places', query: { kind, q, lat, lon, radius, limit }, signal }),
+ foods: {
+ /** The food list with store prices (public), by MyPlate group and/or a search. */
+ list: ({ group, q, signal } = {}) => call({ path: '/api/v1/foods', query: { group, q }, signal }),
+ get: (slug, { signal } = {}) => call({ path: `/api/v1/foods/${enc(String(slug))}`, signal }),
+ },
+ plans: {
+ /** A meal plan for 1–14 days (food.plan-create-request@1) → the plan, saved as the caller's. */
+ create: ({ people, days, budget, title } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/plans', json: { people, days, budget, title }, idempotencyKey: false, signal }),
+ list: ({ limit, signal } = {}) => call({ path: '/api/v1/plans', query: { limit }, signal }),
+ get: (id, { signal } = {}) => call({ path: `/api/v1/plans/${planId(id)}`, signal }),
+ },
+ pantry: {
+ /** The caller's pantry: { items, foods, suggestions: { can_make, almost }, note }. */
+ get: ({ signal } = {}) => call({ path: '/api/v1/pantry', signal }),
+ /** Replace the pantry with these food slugs (at most 200; GET foods lists them). */
+ set: (items, { signal } = {}) => {
+ if (!Array.isArray(items)) throw new TypeError('food.pantry.set: items is an array of food slugs');
+ if (items.length > 200) throw new TypeError('food.pantry.set: at most 200 items');
+ return call({ method: 'PUT', path: '/api/v1/pantry', json: { items: items.map(String) }, signal });
+ },
+ },
+ recipes: {
+ /** Recipe ideas from the pantry through OpenVibe.AI (10 a day per person); ask and people are optional. */
+ suggest: ({ ask, people } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/recipes/suggest', json: { ask, people }, idempotencyKey: false, signal }),
+ /** The caller's last 20 suggestions. */
+ list: ({ signal } = {}) => call({ path: '/api/v1/recipes', signal }),
+ },
+ };
+}
+
+module.exports = { createFoodClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * openvibe-sdk/run — OpenVibe.Run for services and apps: run a job on a worker node (a machine that offers
+ * worker:<class>, placed by Run and dispatched through Bot), follow it and cancel it. Routes at
+ * https://openvibe.run/api/v1; tokens for audience openvibe.run that name a project (the job is the project's; a
+ * first-party service acting for a person names the project with X-OV-Project, see `forProject`).
+ *
+ * const run = createRunClient(createClient({ tokenProvider }));
+ * const { job } = await run.jobs.submit({ class: 'function', artifact: { name: 'thumbnailer', version: '1.2.0' }, args: { width: 320 }, limits: { wall_ms: 120000, cpu_ms: 60000, mem_bytes: 512e6 } });
+ * for await (const e of run.jobs.events(job.id)) console.log(e.event, e.data); // until the job's `end` event
+ * const { jobs, next_cursor } = await run.jobs.list({ state: 'running' });
+ * await run.jobs.cancel(job.id); // a request: cancelled once the node stops
+ * const { stream_url } = await run.jobs.streamTicket(job.id); // a two-minute, one-use URL for a browser's EventSource
+ *
+ * submit carries an Idempotency-Key (Run answers a repeat with the first job, 200), so it is retried safely; pass
+ * idempotency_key in the body to choose it. Cancelling asks the node to stop; the job reaches `cancelled` when it has.
+ * Errors are OpenVibeError: run.invalid_request (400), run.project_required (403), run.job_not_found (404, also for
+ * another project's job), run.idempotency.conflict (409), run.args_too_large (413), run.limits.exceeded,
+ * run.invalid_input (422), run.stream.busy / run.stream.disabled (503), …
+ */
+const { parseSSE } = require('./realtime');
+
+const enc = encodeURIComponent;
+const JOB_ID_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/; // Run's own rule
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const STATES = ['queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired'];
+
+function createRunClient(client, { baseUrl, project } = {}) {
+ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createRunClient: pass an openvibe-sdk/core client');
+ if (project != null && !PROJECT_RE.test(String(project))) throw new TypeError('createRunClient: project is a prj_ id');
+ const at = { service: 'run', baseUrl, audience: 'openvibe.run', ...(project ? { headers: { 'X-OV-Project': String(project) } } : {}) };
+ const call = (opts) => client.json({ ...at, ...opts, headers: { ...(at.headers || {}), ...(opts.headers || {}) } });
+ const job = (v) => {
+ if (!JOB_ID_RE.test(String(v || ''))) throw new TypeError('run: a job is a job_ id');
+ return enc(String(v));
+ };
+
+ /**
+ * The job's events as they happen (SSE run.job-stream-event@1: { id, event, data }), replayed after lastEventId
+ * first; it ends with the job's `end` event, after which Run closes the stream. Needs run.job.stream.
+ */
+ async function* events(jobId, { lastEventId = 0, signal } = {}) {
+ const res = await client.request({
+ ...at, path: `/api/v1/jobs/${job(jobId)}/stream`, responseType: 'response', retries: 0, signal,
+ headers: { ...(at.headers || {}), Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(Number(lastEventId) || 0) },
+ });
+ for await (const msg of parseSSE(res.data.body)) {
+ let data = null;
+ try { data = JSON.parse(msg.data); } catch { data = msg.data; }
+ const n = Number(msg.id);
+ yield { id: Number.isFinite(n) && msg.id !== undefined && msg.id !== '' ? n : null, event: msg.event, data };
+ if (msg.event === 'end') return;
+ }
+ }
+
+ return {
+ jobs: {
+ /** Submit a job (run.job-create-request@1: class and limits required) → { job } (201, or 200 for a repeat). */
+ submit: (body, { idempotencyKey, signal } = {}) => {
+ if (!body || typeof body.class !== 'string' || !body.limits) throw new TypeError('run.jobs.submit: class and limits are required');
+ const key = idempotencyKey || body.idempotency_key;
+ return call({ method: 'POST', path: '/api/v1/jobs', json: body, ...(key ? { idempotencyKey: String(key) } : {}), signal });
+ },
+ /** The project's jobs, newest first: { jobs, next_cursor }. */
+ list: ({ state, limit, cursor, signal } = {}) => {
+ if (state != null && !STATES.includes(state)) throw new TypeError(`run.jobs.list: state is one of ${STATES.join(', ')}`);
+ return call({ path: '/api/v1/jobs', query: { state, limit, cursor }, signal });
+ },
+ get: (jobId, { signal } = {}) => call({ path: `/api/v1/jobs/${job(jobId)}`, signal }),
+ /** Ask the job to stop; asking twice is harmless. */
+ cancel: (jobId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/jobs/${job(jobId)}/cancel`, idempotent: true, signal }),
+ /** A one-use ticket for a browser: { ticket, expires_at, expires_in, stream_url, job_id }. */
+ streamTicket: (jobId, { signal } = {}) => call({ method: 'POST', path: `/api/v1/jobs/${job(jobId)}/stream/ticket`, idempotent: true, signal }),
+ events,
+ },
+ /** The same client for a project named by X-OV-Project (a first-party service acting for one of its admins). */
+ forProject: (p) => createRunClient(client, { baseUrl, project: p }),
+ };
+}
+
+module.exports = { createRunClient, RUN_JOB_STATES: STATES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * openvibe-sdk/video — OpenVibe.Video's playlists for apps: a person's or an app's playlists of recordings and clips
+ * from openvibe.video (stream recordings and clips from OpenVibe.Live, and uploads), and anyone's public playlists.
+ * Routes at https://openvibe.video/api/v1/video; the media hub's tokens are for audience openvibe.download (one
+ * service, one audience, shared with the drive).
+ *
+ * const video = createVideoClient(createClient({ tokenProvider }));
+ * const { playlist } = await video.playlists.create({ title: 'Best of October', visibility: 'public' });
+ * await video.playlists.items.add(playlist.id, { kind: 'vod', id: 5579 });
+ * await video.playlists.items.add(playlist.id, { kind: 'clip', id: 1203 });
+ * await video.playlists.items.reorder(playlist.id, [{ kind: 'clip', id: 1203 }, { kind: 'vod', id: 5579 }]);
+ * const { playlist: p, items } = await video.playlists.get(playlist.id); // public and unlisted: anyone
+ * const theirs = await video.users.playlists('goosely'); // a person's public playlists
+ *
+ * Visibility is public, unlisted or private; a private playlist is 404 to everyone but its owner. An app's playlists
+ * are its own (owner app:…), recorded with its project. Creating a playlist or adding an item makes a new one each
+ * time, so neither is retried on its own; changes and deletes are.
+ * Errors are OpenVibeError: playlist.not_found, playlist.item_not_found (404), playlist.item_duplicate (409),
+ * playlist.invalid (422), capability.denied, user.not_found, …
+ */
+const enc = encodeURIComponent;
+const PLAYLIST_ID_RE = /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/; // the media hub's own rules
+const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
+const KINDS = ['vod', 'clip'];
+
+function createVideoClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createVideoClient: pass an openvibe-sdk/core client');
+ const at = { service: 'media-hub', baseUrl, audience: 'openvibe.download' };
+ const call = (opts) => client.json({ ...at, ...opts });
+ const playlist = (id) => {
+ if (!PLAYLIST_ID_RE.test(String(id || ''))) throw new TypeError('video: a playlist is a vpl_ id');
+ return `/api/v1/video/playlists/${enc(String(id))}`;
+ };
+ const item = ({ kind, id } = {}) => {
+ if (!KINDS.includes(kind)) throw new TypeError("video: an item's kind is 'vod' or 'clip'");
+ if (!Number.isInteger(Number(id)) || Number(id) < 1) throw new TypeError("video: an item's id is the recording's or clip's number");
+ return { kind, id: Number(id) };
+ };
+
+ return {
+ playlists: {
+ /** The caller's own playlists (a person, or an app with video.playlist.read). */
+ list: ({ signal } = {}) => call({ path: '/api/v1/video/playlists', signal }),
+ /** { title, description?, visibility? } → { playlist }. */
+ create: ({ title, description, visibility } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/video/playlists', json: { title, description, visibility }, idempotencyKey: false, signal }),
+ /** One playlist and its items, in order: { playlist (with owner), items: [{ kind, id, position, added_at }] }. */
+ get: (id, { signal } = {}) => call({ path: playlist(id), signal }),
+ update: (id, patch, { signal } = {}) => call({ method: 'PATCH', path: playlist(id), json: patch, idempotent: true, signal }),
+ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: playlist(id), signal }),
+ items: {
+ add: (id, it, { signal } = {}) => call({ method: 'POST', path: `${playlist(id)}/items`, json: item(it), idempotencyKey: false, signal }),
+ /** The full new order: every item, each once → { items }. */
+ reorder: (id, items, { signal } = {}) => {
+ if (!Array.isArray(items)) throw new TypeError('video.playlists.items.reorder: items is the full ordered list');
+ return call({ method: 'PATCH', path: `${playlist(id)}/items`, json: { items: items.map(item) }, idempotent: true, signal });
+ },
+ remove: (id, it, { signal } = {}) => {
+ const { kind, id: itemId } = item(it);
+ return call({ method: 'DELETE', path: `${playlist(id)}/items/${kind}/${itemId}`, signal });
+ },
+ },
+ },
+ users: {
+ /** A person's public playlists, newest first: { playlists, next_cursor }. */
+ playlists: (username, { limit, cursor, signal } = {}) => {
+ if (!USERNAME_RE.test(String(username || ''))) throw new TypeError('video.users.playlists: a username');
+ return call({ path: `/api/v1/video/users/${enc(String(username))}/playlists`, query: { limit, cursor }, signal });
+ },
+ },
+ };
+}
+
+module.exports = { createVideoClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * openvibe-sdk/work — OpenVibe.Work for apps: search the job board (listings gathered from public job boards, each
+ * linking to the board's own posting), the sources it reads, a person's saved searches, and the applications they
+ * track. Routes at https://openvibe.work/api/v1; tokens for audience openvibe.work.
+ *
+ * const work = createWorkClient(createClient({ tokenProvider }));
+ * const { jobs, next, facets } = await work.jobs.search({ q: 'node', remote: true }); // public
+ * const { sources } = await work.sources(); // public: what Work reads, how often
+ * const app = await work.applications.create({ listing_id: jobs[0].id, status: 'saved' });
+ * await work.applications.update(app.id, { status: 'applied', applied_at: new Date().toISOString() });
+ * await work.savedSearches.create({ q: 'robotics', remote: true }); // a person's token only
+ *
+ * Applications are a person's own: their token, or an app holding work.application.manage. Saved searches and the
+ * agent's help with an application (`applications.assist`) are the person's alone. Tracking a listing a second time
+ * answers the application already tracked (200), so creating one from a listing is retried safely; without a listing
+ * it is never retried on its own.
+ * Errors are OpenVibeError: work.jobs.cursor_invalid, work.applications.status_invalid, work.applications_full (409),
+ * work.applications.not_found, work.saved_searches.empty, work.saved_searches.limit (429),
+ * work.saved_searches.person_only, work.actor_unavailable (503), …
+ */
+const enc = encodeURIComponent;
+const APPLICATION_ID_RE = /^wap_[0-9A-HJKMNP-TV-Z]{26}$/; // Work's own rule
+const STATUSES = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];
+
+function createWorkClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createWorkClient: pass an openvibe-sdk/core client');
+ const at = { service: 'work', baseUrl, audience: 'openvibe.work' };
+ const call = (opts) => client.json({ ...at, ...opts });
+ const application = (id) => {
+ if (!APPLICATION_ID_RE.test(String(id || ''))) throw new TypeError('work: an application is a wap_ id');
+ return `/api/v1/me/applications/${enc(String(id))}`;
+ };
+ const status = (s) => {
+ if (s != null && !STATUSES.includes(s)) throw new TypeError(`work: status is one of ${STATUSES.join(', ')}`);
+ return s;
+ };
+ const bool = (v) => (v == null ? undefined : String(Boolean(v) && v !== 'false'));
+
+ return {
+ jobs: {
+ /** Search the listings (public): { jobs, next, count, filters, facets, sources }. Page with cursor = next. */
+ search: ({ q, remote, type, location, limit, cursor, signal } = {}) => call({ path: '/api/v1/jobs', query: { q, remote: bool(remote), type, location, limit, cursor }, signal }),
+ get: (id, { signal } = {}) => call({ path: `/api/v1/jobs/${enc(String(id))}`, signal }),
+ },
+ /** The boards Work reads, their terms and attribution, and when each was last fetched (public). */
+ sources: ({ signal } = {}) => call({ path: '/api/v1/sources', signal }),
+ savedSearches: {
+ list: ({ signal } = {}) => call({ path: '/api/v1/saved-searches', signal }),
+ /** At least one of q, remote, type or location. */
+ create: ({ q, remote, type, location } = {}, { signal } = {}) => call({ method: 'POST', path: '/api/v1/saved-searches', json: { q, remote: bool(remote), type, location }, idempotencyKey: false, signal }),
+ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: `/api/v1/saved-searches/${enc(String(id))}`, signal }),
+ },
+ applications: {
+ /** The person's tracked applications, newest first: { applications, next }. */
+ list: ({ status: st, limit, cursor, signal } = {}) => call({ path: '/api/v1/me/applications', query: { status: status(st), limit, cursor }, signal }),
+ /** Track one (work.application-write-request@1): a listing_id, or a title/company/url of your own. */
+ create: (fields, { signal } = {}) => {
+ if (!fields || typeof fields !== 'object') throw new TypeError('work.applications.create: the application fields');
+ status(fields.status);
+ return call({ method: 'POST', path: '/api/v1/me/applications', json: fields, ...(fields.listing_id ? { idempotent: true } : { idempotencyKey: false }), signal });
+ },
+ get: (id, { signal } = {}) => call({ path: application(id), signal }),
+ update: (id, patch, { signal } = {}) => {
+ status(patch && patch.status);
+ return call({ method: 'PATCH', path: application(id), json: patch, idempotent: true, signal });
+ },
+ remove: (id, { signal } = {}) => call({ method: 'DELETE', path: application(id), signal }),
+ /** Ask the person's OpenVibe.Actor agent for help with it (a cover letter, prep): { task_id, task_url }. */
+ assist: (id, { ask } = {}, { signal } = {}) => call({ method: 'POST', path: `${application(id)}/assist`, json: { ask }, idempotencyKey: false, signal }),
+ },
+ };
+}
+
+module.exports = { createWorkClient, WORK_APPLICATION_STATUSES: STATUSES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * openvibe-sdk/zone — OpenVibe.Zone for apps: a developer project's object zones (named storage spaces in
+ * OpenVibe.Media, one set per environment, each with an optional quota) and their usage. Routes at
+ * https://openvibe.zone/api/v1; tokens for audience openvibe.zone. Upload into a zone with openvibe-sdk/media's
+ * object API.
+ *
+ * const zone = createZoneClient(createClient({ tokenProvider }));
+ * const z = await zone.zones.create('prj_01…', { env: 'production', name: 'user-uploads', quota_bytes: 50e9 });
+ * const { zones, next_cursor } = await zone.zones.list('prj_01…', { env: 'production' });
+ * const usage = await zone.zones.usage(z.id); // Media's live snapshot and this month's metered series
+ * await zone.zones.update(z.id, { quota_bytes: 100e9 });
+ * await zone.zones.remove(z.id, { recursive: true }); // 202: deleting, with everything in it
+ *
+ * Who may act on a project: an app of that project holding the route's capability (a sandbox app only in the sandbox),
+ * or a person who is a member of it (any role reads; admins and owners change). Anyone else is told the project or
+ * zone does not exist. create carries an Idempotency-Key (Zone answers a repeat with the same zone), so it is retried
+ * safely. Errors are OpenVibeError: zone.name_taken, zone.name_retired (409), zone.not_empty, zone.held,
+ * zone.bad_request, …
+ */
+const enc = encodeURIComponent;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/; // Zone's own rules
+const ZONE_RE = /^zon_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ENVS = ['sandbox', 'production'];
+
+function createZoneClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function') throw new TypeError('createZoneClient: pass an openvibe-sdk/core client');
+ const at = { service: 'zone', baseUrl, audience: 'openvibe.zone' };
+ const call = (opts) => client.json({ ...at, ...opts });
+ const project = (p) => {
+ if (!PROJECT_RE.test(String(p || ''))) throw new TypeError('zone: a project is a prj_ id');
+ return `/api/v1/projects/${enc(String(p))}/zones`;
+ };
+ const zonePath = (z, rest = '') => {
+ if (!ZONE_RE.test(String(z || ''))) throw new TypeError('zone: a zone is a zon_ id');
+ return `/api/v1/zones/${enc(String(z))}${rest}`;
+ };
+ const env = (e) => {
+ if (e != null && !ENVS.includes(e)) throw new TypeError(`zone: env is ${ENVS.join(' or ')}`);
+ return e;
+ };
+
+ return {
+ zones: {
+ /** A new zone (zone.object-zone-create-request@1: env, name, description?, quota_bytes?) → the zone. */
+ create: (p, { env: e, name, description, quota_bytes: quota } = {}, { idempotencyKey, signal } = {}) => {
+ if (!env(e)) throw new TypeError('zone.zones.create: env is required');
+ return call({ method: 'POST', path: project(p), json: { env: e, name, description, quota_bytes: quota }, ...(idempotencyKey ? { idempotencyKey: String(idempotencyKey) } : {}), signal });
+ },
+ /** The project's zones: { zones, next_cursor }. */
+ list: (p, { env: e, include_deleted: includeDeleted, limit, cursor, signal } = {}) => call({ path: project(p), query: { env: env(e), include_deleted: includeDeleted ? 'true' : undefined, limit, cursor }, signal }),
+ /** One zone, with its stored bytes and objects. */
+ get: (z, { signal } = {}) => call({ path: zonePath(z), signal }),
+ /** Change the description or the quota (null for none). */
+ update: (z, { description, quota_bytes: quota } = {}, { signal } = {}) => call({ method: 'PATCH', path: zonePath(z), json: { description, quota_bytes: quota }, idempotent: true, signal }),
+ /** Delete it; recursive deletes what it holds too (without it, a zone with objects is zone.not_empty). */
+ remove: (z, { recursive = false, signal } = {}) => call({ method: 'DELETE', path: zonePath(z), query: { recursive: recursive ? 'true' : undefined }, signal }),
+ /** Media's live snapshot and this month's metered series (requests, egress, byte-hours). */
+ usage: (z, { signal } = {}) => call({ path: zonePath(z, '/usage'), signal }),
+ },
+ };
+}
+
+module.exports = { createZoneClient };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './inventory', './limits', './live', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './tips', './usage', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './cache', './commerce', './cost', './db', './delivery', './download', './events', './food', './govern', './identity', './inventory', './limits', './live', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './run', './runtime', './service', './sso', './storage', './telemetry', './testing', './tips', './usage', './valkey', './video', './vip', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,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, 50);
+ assert.equal(subpaths.length, 56);
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,229 @@
@@ -0,0 +1,229 @@
+'use strict';
+// openvibe-sdk/food, /actor, /run, /video, /work and /zone through a real core client against one local stub: every
+// route and method, the token audience, which writes are retried (keyed or idempotent) and which never are, the SSE
+// followers (Actor's task events, Run's job stream), and the id checks done before any call.
+const assert = require('assert');
+const http = require('http');
+const { createClient } = require('../src/core');
+const { createFoodClient } = require('../src/food');
+const { createActorClient } = require('../src/actor');
+const { createRunClient } = require('../src/run');
+const { createVideoClient } = require('../src/video');
+const { createWorkClient } = require('../src/work');
+const { createZoneClient } = require('../src/zone');
+
+const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ids = { plan: `pln_${ULID}`, task: `tsk_${ULID}`, agent: `agt_${ULID}`, mem: `mem_${ULID}`, job: `job_${ULID}`, playlist: `vpl_${ULID}`, app: `wap_${ULID}`, project: `prj_${ULID}`, zone: `zon_${ULID}` };
+
+(async () => {
+ const seen = [];
+ const attempts = {};
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ const svc = req.url.split('/')[1];
+ const url = req.url.slice(svc.length + 1);
+ seen.push({ svc, method: req.method, url, idem: req.headers['idempotency-key'] || null, project: req.headers['x-ov-project'] || null, lastEvent: req.headers['last-event-id'] || null, auth: req.headers.authorization || null, body: body ? JSON.parse(body) : null });
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ const key = `${req.method} ${req.url} ${body}`; // one 503 per distinct request
+ attempts[key] = (attempts[key] || 0) + 1;
+ // Every write answers 503 once: a write the client may repeat is retried, one it may not is thrown.
+ if (req.method !== 'GET' && attempts[key] === 1 && !/stream\/ticket|\/route$/.test(req.url)) return json(503, { type: 'about:blank', title: 'busy', status: 503 });
+ if (/\/events$|\/stream$/.test(url)) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ res.write('id: 3\nevent: state\ndata: {"state":"running"}\n\n');
+ res.write(': keep-alive\n\n');
+ res.end('id: 4\nevent: end\ndata: {"state":"succeeded"}\n\n');
+ return undefined;
+ }
+ return json(req.method === 'POST' ? 201 : 200, { ok: true });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const audiences = [];
+ const client = createClient({
+ baseUrls: { food: `${base}/food`, actor: `${base}/actor`, run: `${base}/run`, 'media-hub': `${base}/video`, work: `${base}/work`, zone: `${base}/zone` },
+ getToken: ({ audience }) => { audiences.push(audience); return `tok-${audience}`; },
+ retries: 2, retryDelayMs: 1, maxRetryDelayMs: 2,
+ });
+ const since = () => { const n = seen.length; return () => seen.slice(n); };
+ const busyOnce = async (fn) => assert.rejects(fn, (e) => e.status === 503, 'a write that is never retried surfaces the 503');
+
+ try {
+ // ── Food ──────────────────────────────────────────────
+ const food = createFoodClient(client);
+ let mark = since();
+ await food.places({ kind: 'foodbank', lat: 45.5, lon: -122.6, radius: 10 });
+ await food.foods.list({ group: 'vegetables', q: 'carrot' });
+ await food.foods.get('carrots');
+ await food.plans.list({ limit: 5 });
+ await food.plans.get(ids.plan);
+ await food.pantry.get();
+ await food.pantry.set(['rice', 'onions']); // PUT: retried
+ await busyOnce(() => food.plans.create({ people: 2, days: 7, budget: 80 }));
+ await busyOnce(() => food.recipes.suggest({ ask: 'quick', people: 2 }));
+ await food.recipes.list();
+ assert.deepStrictEqual(mark().map((r) => `${r.method} ${r.url}`), [
+ 'GET /api/v1/places?kind=foodbank&lat=45.5&lon=-122.6&radius=10', 'GET /api/v1/foods?group=vegetables&q=carrot', 'GET /api/v1/foods/carrots',
+ 'GET /api/v1/plans?limit=5', `GET /api/v1/plans/${ids.plan}`, 'GET /api/v1/pantry', 'PUT /api/v1/pantry', 'PUT /api/v1/pantry',
+ 'POST /api/v1/plans', 'POST /api/v1/recipes/suggest', 'GET /api/v1/recipes',
+ ]);
+ assert.throws(() => food.plans.get('nope'), /pln_/);
+ assert.throws(() => food.pantry.set('rice'), /array/);
+
+ // ── Actor ─────────────────────────────────────────────
+ const actor = createActorClient(client);
+ mark = since();
+ await actor.agents.list();
+ await actor.route({ task: 'sum it up', mode: 'cheapest' });
+ await actor.tasks.create({ task: 'Summarize', idempotency_key: 'job-0042' }); // keyed in the body: retried
+ await busyOnce(() => actor.tasks.create({ task: 'Again, unkeyed' }));
+ await actor.tasks.list({ limit: 10 });
+ await actor.tasks.get(ids.task);
+ await actor.tasks.cancel(ids.task); // retried
+ const evs = [];
+ for await (const e of actor.tasks.events(ids.task, { lastEventId: 2 })) evs.push(e);
+ assert.deepStrictEqual(evs, [{ id: 3, event: 'state', data: { state: 'running' } }, { id: 4, event: 'end', data: { state: 'succeeded' } }]);
+ await actor.me.agents.list();
+ await busyOnce(() => actor.me.agents.create({ name: 'Brief', instructions: 'x' }));
+ await actor.me.agents.get(ids.agent);
+ await actor.me.agents.update(ids.agent, { status: 'paused' });
+ await actor.me.agents.remove(ids.agent);
+ await busyOnce(() => actor.me.agents.run(ids.agent, { input: 'today' }));
+ await actor.me.agents.runs(ids.agent, { limit: 3 });
+ await actor.me.agents.memory.list(ids.agent);
+ await busyOnce(() => actor.me.agents.memory.add(ids.agent, 'I live in Portland'));
+ await actor.me.agents.memory.update(ids.agent, ids.mem, 'Portland, Oregon');
+ await actor.me.agents.memory.remove(ids.agent, ids.mem);
+ await actor.me.agents.memory.clear(ids.agent);
+ const a = mark();
+ assert.deepStrictEqual(a.map((r) => `${r.method} ${r.url}`), [
+ 'GET /api/v1/agents', 'POST /api/v1/route', 'POST /api/v1/tasks', 'POST /api/v1/tasks', 'POST /api/v1/tasks',
+ 'GET /api/v1/tasks?limit=10', `GET /api/v1/tasks/${ids.task}`, `POST /api/v1/tasks/${ids.task}/cancel`, `POST /api/v1/tasks/${ids.task}/cancel`,
+ `GET /api/v1/tasks/${ids.task}/events`, 'GET /api/v1/me/agents', 'POST /api/v1/me/agents', `GET /api/v1/me/agents/${ids.agent}`,
+ `PATCH /api/v1/me/agents/${ids.agent}`, `PATCH /api/v1/me/agents/${ids.agent}`, `DELETE /api/v1/me/agents/${ids.agent}`, `DELETE /api/v1/me/agents/${ids.agent}`,
+ `POST /api/v1/me/agents/${ids.agent}/run`, `GET /api/v1/me/agents/${ids.agent}/runs?limit=3`, `GET /api/v1/me/agents/${ids.agent}/memory`,
+ `POST /api/v1/me/agents/${ids.agent}/memory`, `PATCH /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`, `PATCH /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`,
+ `DELETE /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`, `DELETE /api/v1/me/agents/${ids.agent}/memory/${ids.mem}`,
+ `DELETE /api/v1/me/agents/${ids.agent}/memory`, `DELETE /api/v1/me/agents/${ids.agent}/memory`,
+ ]);
+ assert.strictEqual(a.find((r) => r.url.endsWith('/events')).lastEvent, '2', 'the stream picks up after lastEventId');
+ assert.deepStrictEqual(a.find((r) => r.url === `/api/v1/me/agents/${ids.agent}/run`).body, { input: 'today' });
+ assert.throws(() => actor.tasks.get('tsk_bad'), /tsk_/);
+ assert.throws(() => actor.tasks.create({ task: ' ' }), /instruction/);
+
+ // ── Run ───────────────────────────────────────────────
+ const run = createRunClient(client);
+ mark = since();
+ const submitted = { class: 'function', artifact: { name: 'thumbnailer', version: '1.2.0' }, limits: { wall_ms: 1000, cpu_ms: 500, mem_bytes: 1e8 } };
+ await run.jobs.submit(submitted); // keyed by the core client: retried with the same key
+ await run.jobs.submit({ ...submitted, idempotency_key: 'thumb-7' });
+ await run.jobs.list({ state: 'running', limit: 5 });
+ await run.jobs.get(ids.job);
+ await run.jobs.cancel(ids.job);
+ await run.jobs.streamTicket(ids.job);
+ const jobEvents = [];
+ for await (const e of run.jobs.events(ids.job)) jobEvents.push(e.event);
+ assert.deepStrictEqual(jobEvents, ['state', 'end']);
+ await run.forProject(ids.project).jobs.get(ids.job);
+ const r = mark();
+ assert.deepStrictEqual(r.map((x) => `${x.method} ${x.url}`), [
+ 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'POST /api/v1/jobs', 'GET /api/v1/jobs?state=running&limit=5', `GET /api/v1/jobs/${ids.job}`,
+ `POST /api/v1/jobs/${ids.job}/cancel`, `POST /api/v1/jobs/${ids.job}/cancel`, `POST /api/v1/jobs/${ids.job}/stream/ticket`,
+ `GET /api/v1/jobs/${ids.job}/stream`, `GET /api/v1/jobs/${ids.job}`,
+ ]);
+ assert.ok(r[0].idem && r[0].idem === r[1].idem, 'the retried submit repeats its Idempotency-Key');
+ assert.deepStrictEqual([r[2].idem, r[3].idem], ['thumb-7', 'thumb-7'], "the body's idempotency_key is the header");
+ assert.strictEqual(r[10].project, ids.project, 'forProject names the project');
+ assert.strictEqual(r[5].project, null);
+ assert.throws(() => run.jobs.list({ state: 'done' }), /state is one of/);
+ assert.throws(() => run.jobs.submit({ class: 'function' }), /limits/);
+ assert.throws(() => run.forProject('nope'), /prj_/);
+
+ // ── Video ─────────────────────────────────────────────
+ const video = createVideoClient(client);
+ mark = since();
+ await video.playlists.list();
+ await busyOnce(() => video.playlists.create({ title: 'Best of', visibility: 'public' }));
+ await video.playlists.get(ids.playlist);
+ await video.playlists.update(ids.playlist, { title: 'Best of October' });
+ await busyOnce(() => video.playlists.items.add(ids.playlist, { kind: 'vod', id: '5579' }));
+ await video.playlists.items.reorder(ids.playlist, [{ kind: 'clip', id: 12 }, { kind: 'vod', id: 5579 }]);
+ await video.playlists.items.remove(ids.playlist, { kind: 'clip', id: 12 });
+ await video.playlists.remove(ids.playlist);
+ await video.users.playlists('goosely', { limit: 10 });
+ const v = mark();
+ assert.deepStrictEqual(v.map((x) => `${x.method} ${x.url}`), [
+ 'GET /api/v1/video/playlists', 'POST /api/v1/video/playlists', `GET /api/v1/video/playlists/${ids.playlist}`,
+ `PATCH /api/v1/video/playlists/${ids.playlist}`, `PATCH /api/v1/video/playlists/${ids.playlist}`, `POST /api/v1/video/playlists/${ids.playlist}/items`,
+ `PATCH /api/v1/video/playlists/${ids.playlist}/items`, `PATCH /api/v1/video/playlists/${ids.playlist}/items`,
+ `DELETE /api/v1/video/playlists/${ids.playlist}/items/clip/12`, `DELETE /api/v1/video/playlists/${ids.playlist}/items/clip/12`,
+ `DELETE /api/v1/video/playlists/${ids.playlist}`, `DELETE /api/v1/video/playlists/${ids.playlist}`, 'GET /api/v1/video/users/goosely/playlists?limit=10',
+ ]);
+ assert.deepStrictEqual(v[5].body, { kind: 'vod', id: 5579 });
+ assert.deepStrictEqual(v[6].body, { items: [{ kind: 'clip', id: 12 }, { kind: 'vod', id: 5579 }] });
+ assert.throws(() => video.playlists.items.add(ids.playlist, { kind: 'paste', id: 1 }), /'vod' or 'clip'/);
+ assert.throws(() => video.playlists.get('pl_1'), /vpl_/);
+
+ // ── Work ──────────────────────────────────────────────
+ const work = createWorkClient(client);
+ mark = since();
+ await work.jobs.search({ q: 'node', remote: true, limit: 20 });
+ await work.jobs.get('rok-123');
+ await work.sources();
+ await work.savedSearches.list();
+ await busyOnce(() => work.savedSearches.create({ q: 'robotics', remote: false }));
+ await work.savedSearches.remove('ssc_1');
+ await work.applications.list({ status: 'applied' });
+ await work.applications.create({ listing_id: 'rok-123', status: 'saved' }); // a listing is tracked once: retried
+ await busyOnce(() => work.applications.create({ title: 'Robot wrangler', company: 'Acme' }));
+ await work.applications.get(ids.app);
+ await work.applications.update(ids.app, { status: 'interviewing' });
+ await work.applications.remove(ids.app);
+ await busyOnce(() => work.applications.assist(ids.app, { ask: 'a cover letter' }));
+ const w = mark();
+ assert.deepStrictEqual(w.map((x) => `${x.method} ${x.url}`), [
+ 'GET /api/v1/jobs?q=node&remote=true&limit=20', 'GET /api/v1/jobs/rok-123', 'GET /api/v1/sources', 'GET /api/v1/saved-searches',
+ 'POST /api/v1/saved-searches', 'DELETE /api/v1/saved-searches/ssc_1', 'DELETE /api/v1/saved-searches/ssc_1', 'GET /api/v1/me/applications?status=applied',
+ 'POST /api/v1/me/applications', 'POST /api/v1/me/applications', 'POST /api/v1/me/applications', `GET /api/v1/me/applications/${ids.app}`,
+ `PATCH /api/v1/me/applications/${ids.app}`, `PATCH /api/v1/me/applications/${ids.app}`, `DELETE /api/v1/me/applications/${ids.app}`,
+ `DELETE /api/v1/me/applications/${ids.app}`, `POST /api/v1/me/applications/${ids.app}/assist`,
+ ]);
+ assert.deepStrictEqual(w[4].body, { q: 'robotics', remote: 'false' });
+ assert.throws(() => work.applications.update(ids.app, { status: 'hired' }), /status is one of/);
+ assert.throws(() => work.applications.get('app_1'), /wap_/);
+
+ // ── Zone ──────────────────────────────────────────────
+ const zone = createZoneClient(client);
+ mark = since();
+ await zone.zones.create(ids.project, { env: 'production', name: 'user-uploads', quota_bytes: 5e10 }, { idempotencyKey: 'zone-create-1' });
+ await zone.zones.list(ids.project, { env: 'sandbox', include_deleted: true, limit: 5 });
+ await zone.zones.get(ids.zone);
+ await zone.zones.update(ids.zone, { quota_bytes: null });
+ await zone.zones.remove(ids.zone, { recursive: true });
+ await zone.zones.usage(ids.zone);
+ const z = mark();
+ assert.deepStrictEqual(z.map((x) => `${x.method} ${x.url}`), [
+ `POST /api/v1/projects/${ids.project}/zones`, `POST /api/v1/projects/${ids.project}/zones`,
+ `GET /api/v1/projects/${ids.project}/zones?env=sandbox&include_deleted=true&limit=5`, `GET /api/v1/zones/${ids.zone}`,
+ `PATCH /api/v1/zones/${ids.zone}`, `PATCH /api/v1/zones/${ids.zone}`, `DELETE /api/v1/zones/${ids.zone}?recursive=true`,
+ `DELETE /api/v1/zones/${ids.zone}?recursive=true`, `GET /api/v1/zones/${ids.zone}/usage`,
+ ]);
+ assert.deepStrictEqual([z[0].idem, z[1].idem], ['zone-create-1', 'zone-create-1']);
+ assert.deepStrictEqual(z[4].body, { quota_bytes: null }, 'null clears the quota');
+ assert.throws(() => zone.zones.create(ids.project, { name: 'x' }), /env is required/);
+ assert.throws(() => zone.zones.get('zone_1'), /zon_/);
+
+ // Each product's token is for its own audience (Video's is the media hub's, openvibe.download).
+ assert.deepStrictEqual([...new Set(audiences)].sort(), ['openvibe.actor', 'openvibe.download', 'openvibe.food', 'openvibe.run', 'openvibe.work', 'openvibe.zone']);
+ assert.ok(seen.every((x) => x.auth === `Bearer tok-${{ food: 'openvibe.food', actor: 'openvibe.actor', run: 'openvibe.run', video: 'openvibe.download', work: 'openvibe.work', zone: 'openvibe.zone' }[x.svc]}`));
+
+ // Each client needs a core client.
+ for (const make of [createFoodClient, createActorClient, createRunClient, createVideoClient, createWorkClient, createZoneClient]) assert.throws(() => make({}), /core client/);
+ console.log('product modules (food, actor, run, video, work, zone): all checks passed');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+/** openvibe-sdk/actor: OpenVibe.Actor — tasks for AI agent systems, and a person's own agents and what they remember. */
+type Opts = { signal?: AbortSignal };
+type ActorJson = Record<string, any>;
+export type ActorMode = 'cheapest' | 'balanced' | 'best' | 'fastest' | 'private';
+export interface TaskRequest {
+ task: string; mode?: ActorMode; budget?: { per_task_usd?: number; per_day_usd?: number }; agent?: string; idempotency_key?: string;
+ webhooks?: Array<{ url: string; events?: string[]; secret_ref?: string }>; [k: string]: unknown;
+}
+export interface TaskEvent { id: number | null; event: string; data: any }
+export interface ActorClient {
+ agents: { list(opts?: Opts): Promise<{ agents: ActorJson[] }> };
+ route(req: { task: string; mode?: ActorMode; agent?: string }, opts?: Opts): Promise<ActorJson>;
+ tasks: {
+ create(req: TaskRequest, opts?: Opts): Promise<ActorJson & { id: string; webhook_secret?: string }>;
+ list(opts?: Opts & { limit?: number; before?: string }): Promise<{ tasks: ActorJson[]; next: string | null }>;
+ get(id: string, opts?: Opts): Promise<ActorJson>;
+ cancel(id: string, opts?: Opts): Promise<ActorJson>;
+ events(id: string, opts?: Opts & { lastEventId?: number }): AsyncGenerator<TaskEvent, void, unknown>;
+ };
+ me: {
+ agents: {
+ list(opts?: Opts): Promise<{ agents: ActorJson[] }>;
+ create(agent: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;
+ get(id: string, opts?: Opts): Promise<ActorJson>;
+ update(id: string, patch: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;
+ remove(id: string, opts?: Opts): Promise<ActorJson>;
+ run(id: string, req?: { input?: string }, opts?: Opts): Promise<ActorJson>;
+ runs(id: string, opts?: Opts & { limit?: number; before?: string }): Promise<{ tasks: ActorJson[]; next: string | null }>;
+ memory: {
+ list(id: string, opts?: Opts): Promise<{ memory: ActorJson[] }>;
+ add(id: string, text: string, opts?: Opts): Promise<ActorJson>;
+ update(id: string, note: string, text: string, opts?: Opts): Promise<ActorJson>;
+ remove(id: string, note: string, opts?: Opts): Promise<ActorJson>;
+ clear(id: string, opts?: Opts): Promise<ActorJson>;
+ };
+ };
+ };
+}
+export function createActorClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): ActorClient;
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+/** openvibe-sdk/food: OpenVibe.Food — places to get food, the food list with prices, meal plans, a pantry and recipe ideas. */
+type Opts = { signal?: AbortSignal };
+type FoodJson = Record<string, any>;
+export interface PantryResult {
+ items: string[]; foods: FoodJson[];
+ suggestions: { can_make: Array<{ slug: string; name: string; serves: number; minutes: number }>; almost: Array<{ slug: string; name: string; serves: number; minutes: number; missing: string[] }> };
+ note: string;
+}
+export interface FoodClient {
+ places(opts?: Opts & { kind?: string; q?: string; lat?: number; lon?: number; radius?: number; limit?: number }): Promise<FoodJson>;
+ foods: {
+ list(opts?: Opts & { group?: string; q?: string }): Promise<{ foods: FoodJson[]; count: number; groups: FoodJson[]; source: FoodJson }>;
+ get(slug: string, opts?: Opts): Promise<{ food: FoodJson; source: FoodJson }>;
+ };
+ plans: {
+ create(req: { people: number; days: number; budget?: number | null; title?: string | null }, opts?: Opts): Promise<FoodJson>;
+ list(opts?: Opts & { limit?: number }): Promise<{ plans: FoodJson[] }>;
+ get(id: string, opts?: Opts): Promise<FoodJson>;
+ };
+ pantry: { get(opts?: Opts): Promise<PantryResult>; set(items: string[], opts?: Opts): Promise<PantryResult> };
+ recipes: { suggest(req?: { ask?: string; people?: number }, opts?: Opts): Promise<FoodJson>; list(opts?: Opts): Promise<{ recipes: FoodJson[] }> };
+}
+export function createFoodClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): FoodClient;
@@ -20,6 +20,12 @@ export * as quest from './quest';
@@ -20,6 +20,12 @@ export * as quest from './quest';
export * as live from './live';
export * as live from './live';
export * as inventory from './inventory';
export * as inventory from './inventory';
export * as tips from './tips';
export * as tips from './tips';
+export * as food from './food';
+export * as actor from './actor';
+export * as run from './run';
+export * as video from './video';
+export * as work from './work';
+export * as zone from './zone';
export * as play from './play';
export * as play from './play';
export * as download from './download';
export * as download from './download';
export * as openre from './openre';
export * as openre from './openre';
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+/** openvibe-sdk/run: OpenVibe.Run — jobs on worker nodes: submit, follow, cancel. */
+type Opts = { signal?: AbortSignal };
+type RunJson = Record<string, any>;
+export type RunJobState = 'queued' | 'placed' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired';
+export const RUN_JOB_STATES: RunJobState[];
+export interface JobRequest {
+ class: string; artifact?: { name: string; version: string }; args?: Record<string, unknown>; inputs?: unknown[];
+ limits: { wall_ms: number; cpu_ms: number; mem_bytes: number }; ttl_ms?: number; egress?: unknown; requirements?: unknown; idempotency_key?: string;
+}
+export interface JobEvent { id: number | null; event: string; data: any }
+export interface RunClient {
+ jobs: {
+ submit(req: JobRequest, opts?: Opts & { idempotencyKey?: string }): Promise<{ created: boolean; job: RunJson }>;
+ list(opts?: Opts & { state?: RunJobState; limit?: number; cursor?: string }): Promise<{ jobs: RunJson[]; next_cursor: string | null }>;
+ get(id: string, opts?: Opts): Promise<RunJson>;
+ cancel(id: string, opts?: Opts): Promise<RunJson>;
+ streamTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string; expires_in: number; stream_url: string; job_id: string }>;
+ events(id: string, opts?: Opts & { lastEventId?: number }): AsyncGenerator<JobEvent, void, unknown>;
+ };
+ forProject(project: string): RunClient;
+}
+export function createRunClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string; project?: string }): RunClient;
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+/** openvibe-sdk/video: OpenVibe.Video's playlists of recordings and clips. */
+type Opts = { signal?: AbortSignal };
+export type PlaylistVisibility = 'public' | 'unlisted' | 'private';
+export interface PlaylistItemRef { kind: 'vod' | 'clip'; id: number }
+export interface Playlist { id: string; title: string; description: string | null; visibility: PlaylistVisibility; item_count: number; created_at: string; updated_at: string; owner?: { kind: string; username?: string | null } }
+export interface PlaylistItem extends PlaylistItemRef { position: number; added_at: string }
+export interface VideoClient {
+ playlists: {
+ list(opts?: Opts): Promise<{ playlists: Playlist[] }>;
+ create(req: { title: string; description?: string; visibility?: PlaylistVisibility }, opts?: Opts): Promise<{ playlist: Playlist }>;
+ get(id: string, opts?: Opts): Promise<{ playlist: Playlist; items: PlaylistItem[] }>;
+ update(id: string, patch: { title?: string; description?: string | null; visibility?: PlaylistVisibility }, opts?: Opts): Promise<{ playlist: Playlist }>;
+ remove(id: string, opts?: Opts): Promise<unknown>;
+ items: {
+ add(id: string, item: PlaylistItemRef, opts?: Opts): Promise<Record<string, any>>;
+ reorder(id: string, items: PlaylistItemRef[], opts?: Opts): Promise<{ items: PlaylistItem[] }>;
+ remove(id: string, item: PlaylistItemRef, opts?: Opts): Promise<unknown>;
+ };
+ };
+ users: { playlists(username: string, opts?: Opts & { limit?: number; cursor?: string }): Promise<{ playlists: Playlist[]; next_cursor: string | null }> };
+}
+export function createVideoClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): VideoClient;
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/** openvibe-sdk/work: OpenVibe.Work — the job board, its sources, saved searches and tracked applications. */
+type Opts = { signal?: AbortSignal };
+type WorkJson = Record<string, any>;
+export type ApplicationStatus = 'saved' | 'applied' | 'interviewing' | 'offer' | 'rejected' | 'withdrawn';
+export const WORK_APPLICATION_STATUSES: ApplicationStatus[];
+export interface ApplicationFields {
+ listing_id?: string; title?: string; company?: string; url?: string; status?: ApplicationStatus; notes?: string;
+ applied_at?: string | null; next_step?: string; next_step_at?: string | null;
+}
+export interface WorkClient {
+ jobs: {
+ search(opts?: Opts & { q?: string; remote?: boolean; type?: string; location?: string; limit?: number; cursor?: string }): Promise<{ jobs: WorkJson[]; next: string | null; count: number; filters: WorkJson; facets: WorkJson; sources: WorkJson[] }>;
+ get(id: string, opts?: Opts): Promise<WorkJson>;
+ };
+ sources(opts?: Opts): Promise<{ sources: WorkJson[]; cadence_hours: number; retention_days: number }>;
+ savedSearches: {
+ list(opts?: Opts): Promise<{ saved_searches: WorkJson[] }>;
+ create(req: { q?: string; remote?: boolean; type?: string; location?: string }, opts?: Opts): Promise<WorkJson>;
+ remove(id: string, opts?: Opts): Promise<unknown>;
+ };
+ applications: {
+ list(opts?: Opts & { status?: ApplicationStatus; limit?: number; cursor?: string }): Promise<{ applications: WorkJson[]; next: string | null }>;
+ create(fields: ApplicationFields, opts?: Opts): Promise<WorkJson>;
+ get(id: string, opts?: Opts): Promise<WorkJson>;
+ update(id: string, patch: ApplicationFields, opts?: Opts): Promise<WorkJson>;
+ remove(id: string, opts?: Opts): Promise<unknown>;
+ assist(id: string, req?: { ask?: string }, opts?: Opts): Promise<{ task_id: string; task_url: string }>;
+ };
+}
+export function createWorkClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): WorkClient;
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+/** openvibe-sdk/zone: OpenVibe.Zone — a project's object zones and their usage. */
+type Opts = { signal?: AbortSignal };
+type ZoneJson = Record<string, any>;
+export type ZoneEnv = 'sandbox' | 'production';
+export interface ZoneClient {
+ zones: {
+ create(project: string, req: { env: ZoneEnv; name: string; description?: string | null; quota_bytes?: number | null }, opts?: Opts & { idempotencyKey?: string }): Promise<ZoneJson>;
+ list(project: string, opts?: Opts & { env?: ZoneEnv; include_deleted?: boolean; limit?: number; cursor?: string }): Promise<{ zones: ZoneJson[]; next_cursor: string | null }>;
+ get(zone: string, opts?: Opts): Promise<ZoneJson>;
+ update(zone: string, patch: { description?: string | null; quota_bytes?: number | null }, opts?: Opts): Promise<ZoneJson>;
+ remove(zone: string, opts?: Opts & { recursive?: boolean }): Promise<ZoneJson>;
+ usage(zone: string, opts?: Opts): Promise<ZoneJson>;
+ };
+}
+export function createZoneClient(client: { json(opts: Record<string, unknown>): Promise<any> }, options?: { baseUrl?: string }): ZoneClient;