openvibers/openvibe.sdk Commits
openvibe-sdk/streams: the Agent Streams S1 client (0.91.0) (#125)
Changes
@@@ -1,5 +1,15 @@# Changelog++## 0.91.0 (2026-11-06)++++- `openvibe-sdk/streams`: the Agent Streams plan, stage 1 — an agent's own stream on OpenVibe.Live (a stream of kind `agent`, id `ags_…`, no stream key and no slot, published with a ticket instead of a key).++ - `startAgentStream({ title, task, visibility, allowSites })` answers `{ stream, publish }`: the stream (live.agent-stream@1) and the publish part (live.agent-stream-publish@1) with the WHIP url, the publish ticket (shown once; Live keeps only its hash) and the moment it dies.++ - `renewTicket(id)` and `revokeTicket(id)`: the ticket lives four hours (the desk renews it at three) and revoking it is the kill switch — Live ends the WHIP session at once.++ - `state(id, { ticket })` is what the desk polls every ten seconds: `{ viewers, encode, curtain, ends_at }`. While `encode` is false the desk stops encoding and sends a snapshot every five seconds with `postFrame(id, bytes, { ticket, contentType })` (JPEG or WebP, at most 200 KB).++ - `setCurtain(id, up, reason)` raises the desk's privacy screen (while it is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why), `setVisibility(id, v)` switches who may watch, `end(id)` ends the stream (the card stays, with the last frame and the VOD) and `viewTicket(id)` mints the player's view ticket (ten minutes).++ - The publish ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket, never the person's token. Every body is checked in the module before it leaves, and a bad id, visibility, curtain reason or an oversized frame is refused with a TypeError.++ - The capability behind it is `live.agent_stream.publish` for the audience `openvibe.live` (Contracts 0.298.0): a Node principal acting for its owner, Codes or Actor. Never a developer-app token, never another person's stream.++## 0.90.0 (2026-11-06)- **Subject assertions** (ADR-003, the 2026-11-06 amendment; plan H1). A money call carries a Network-signed@@@ -2,16 +2,16 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.90.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.91.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.--After v0.90.0 is tagged:++After v0.91.0 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).@@@ -195,6 +195,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) || `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count || `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/streams` | server | `createStreamsClient(client, {baseUrl?})`: an agent's own stream on OpenVibe.Live (the Agent Streams plan, S1; `live.agent-stream@1`, an `ags_…` id with no stream key and no slot). `startAgentStream({title, task, visibility, allowSites})` → `{stream, publish}` (the WHIP url and the one-time `wpt_…` ticket with its expiry), `renewTicket(id)` and `revokeTicket(id)` (the kill switch: Live ends the WHIP session at once), `state(id, {ticket})` (what the desk polls: viewers, `encode`, the curtain, `ends_at`), `postFrame(id, bytes, {ticket, contentType})` (the last frame while Live wants no video, JPEG or WebP at most 200 KB), `setCurtain(id, up, reason)`, `setVisibility(id, v)`, `end(id)` and `viewTicket(id)`. The publish ticket is a bearer of its own on the desk routes; a bad id, visibility, curtain reason or an oversized frame is refused before anything leaves. [Docs](docs/streams.md) || `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/blog` | server | `createBlogClient(client)`: OpenVibe.Blog for the person who authorized the app. `blogs.get/posts(handle, {limit, offset, all})/feed` (JSON Feed), `posts.create(handle, {title, body, summary, visibility, …})` → `{ post, revision }`, `posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n)`. Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience `openvibe.blog` |@@@ -3,11 +3,11 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.90.0",++ "version": "0.91.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",-- "contracts": "openvibe-contracts v0.297.0",++ "contracts": "openvibe-contracts v0.298.0","features": ["exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check","locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",@@@ -41,7 +41,8 @@"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)","consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side","openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps",++ "streams (openvibe-sdk/streams): the Agent Streams plan S1 — start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"],"updated": "2026-11-06"}@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.90.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.91.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -48,7 +48,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.90.0';++const SDK_VERSION = '0.91.0';module.exports = {createClient,@@@ -953,6 +953,9 @@"storageOffer": "function","validateStorageOffer": "function"},++ "openvibe-sdk/streams": {++ "createStreamsClient": "function"++ },"openvibe-sdk/telemetry": {"createPgTelemetrySink": "function","createTelemetry": "function",nnew file mode 100644@@@ -0,0 +1,70 @@++# The Agent Streams (S1)++++`openvibe-sdk/streams` drives an agent's own stream on OpenVibe.Live: the Agent Streams plan, stage 1.++++An agent stream is a Live stream of kind `agent` (its id starts `ags_`). It never has a stream key and never takes a++normal slot; it is published with a **publish ticket** instead, and the ticket is what opens the door.++++```js++const { createClient } = require('openvibe-sdk/core');++const { createStreamsClient } = require('openvibe-sdk/streams');++++const streams = createStreamsClient(createClient({ tokenProvider }));++++const { stream, publish } = await streams.startAgentStream({++ title: 'Fixing the login page',++ task: { source: 'codes', ref: 'task_01JAB…', title: 'Fix the login page' },++ visibility: 'private',++});++// publish.whip_url, publish.ticket (shown once), publish.expires_at++```++++## The desk's loop++++The desk (OpenVibe.Node) polls Live every ten seconds and keeps publishing while Live wants video:++++```js++const s = await streams.state(stream.id, { ticket: publish.ticket });++// { viewers, encode, curtain: { up }, ends_at }++++if (!s.encode) await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket });++```++++While `encode` is false the desk stops encoding and sends a snapshot every five seconds (JPEG or WebP, at most++200 KB). While the curtain is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why.++++## The privacy screen++++```js++await streams.setCurtain(stream.id, true, 'password_field'); // up: a password field is focused++await streams.setCurtain(stream.id, false, 'password_field'); // down: the detectors are clear again++```++++The curtain comes down only from the desk (after its detectors report clear for two seconds) or from the owner's own++button. A detector that fails or is unsure puts the curtain up and never down. The reason is the owner's only.++++## The ticket++++The ticket lives four hours; the desk renews it at three.++++```js++const again = await streams.renewTicket(stream.id); // a new ticket; the old one stops working++await streams.revokeTicket(stream.id); // the kill switch: Live ends the WHIP session at once++```++++The ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket instead of++the person's token, so Live never sees the person's token there.++++## Watching++++```js++await streams.setVisibility(stream.id, 'unlisted'); // private | unlisted | public++const { ticket: view } = await streams.viewTicket(stream.id); // wvt_…, ten minutes++await streams.end(stream.id); // the card stays, with the last frame and the VOD++```++++## Who may call++++The person's own session token, or a first-party service token acting for that person: a Node principal acting for its++owner (Network issues the node token with `live.agent_stream.publish` for the audience `openvibe.live` and names its++owner in `on_behalf_of`), Codes or Actor. Never a developer-app token, never another person's stream.nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/streams.js. Do not edit.++import cjs from '../src/streams.js';++++export const {++ createStreamsClient,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.90.0",++ "version": "0.91.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.90.0",++ "version": "0.91.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -14,7 +14,7 @@"devDependencies": {"@electric-sql/pglite": "^0.5.8","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0","pg": "^8.23.0"},"engines": {@@@ -198,9 +198,9 @@"license": "MIT"},"node_modules/openvibe-contracts": {-- "version": "0.297.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",-- "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",++ "version": "0.298.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",++ "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==","dev": true,"license": "AGPL-3.0-only","dependencies": {@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.90.0",++ "version": "0.91.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",@@@ -299,6 +299,12 @@"import": "./esm/live.mjs","default": "./src/live.js"},++ "./streams": {++ "types": "./types/streams.d.ts",++ "browser": null,++ "import": "./esm/streams.mjs",++ "default": "./src/streams.js"++ },"./inventory": {"types": "./types/inventory.d.ts","browser": null,@@@ -526,7 +532,7 @@"devDependencies": {"@electric-sql/pglite": "^0.5.8","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0","pg": "^8.23.0"},"peerDependencies": {@@@ -68,6 +68,7 @@ const ENTRIES = {'download.mjs': '../src/download.js','quest.mjs': '../src/quest.js','live.mjs': '../src/live.js',++ 'streams.mjs': '../src/streams.js','inventory.mjs': '../src/inventory.js','tips.mjs': '../src/tips.js','blog.mjs': '../src/blog.js',@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.90.0';++const SDK_VERSION = '0.91.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,202 @@++'use strict';++/**++ * openvibe-sdk/streams — the Agent Streams plan (S1): an agent's own stream on OpenVibe.Live.++ *++ * An agent stream is a Live stream of kind 'agent' (id ags_…): it has no stream key and takes no slot, and it is++ * published with a ticket (wpt_…) instead of one. The desk (OpenVibe.Node) or a person's browser (a Codes task page)++ * creates it, keeps the ticket and publishes through it; the owner watches the stream on the page Live answers with.++ *++ * const streams = createStreamsClient(createClient({ tokenProvider }));++ * const { stream, publish } = await streams.startAgentStream({++ * title: 'Fixing the login page',++ * task: { source: 'codes', ref: 'task_…', title: 'Fix the login page' },++ * visibility: 'private',++ * });++ * await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket }); // a snapshot while Live wants no video++ * const s = await streams.state(stream.id, { ticket: publish.ticket }); // { viewers, encode, curtain, ends_at }++ * await streams.setCurtain(stream.id, true, 'password_field'); // the desk's privacy screen++ * await streams.renewTicket(stream.id); // the ticket lives four hours++ * await streams.end(stream.id);++ *++ * The publish ticket is a bearer of its own: on the desk's routes (the state poll and the frame upload) the desk sends++ * the ticket instead of the person's token, so Live never sees the person's token there. The ticket is shown once and++ * Live keeps only its hash.++ */++const AGENT_STREAM_ID = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;++const VISIBILITIES = ['private', 'unlisted', 'public'];++const TASK_SOURCES = ['codes', 'actor', 'desk', 'node'];++const CURTAIN_REASONS = ['password_field', 'secret_file', 'env_print', 'manual', 'site', 'detector'];++const FRAME_TYPES = ['image/jpeg', 'image/webp'];++const MAX_FRAME_BYTES = 200 * 1024;++++/** The frame's bytes, whatever the caller has: a Uint8Array, a Buffer or an ArrayBuffer. */++function frameBytes(v) {++ if (v instanceof ArrayBuffer) return new Uint8Array(v);++ if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);++ throw new TypeError('streams: a frame is bytes (a Uint8Array or an ArrayBuffer)');++}++++/**++ * createStreamsClient(client, { baseUrl }) — the agent-stream routes of OpenVibe.Live.++ * The client is an openvibe-sdk/core client; its token is the person's (or the service's) token.++ */++function createStreamsClient(client, { baseUrl } = {}) {++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createStreamsClient: pass an openvibe-sdk/core client');++ const at = { service: 'live', baseUrl, audience: 'openvibe.live' };++ // A write is never retried on its own: Live keys no agent-stream route by Idempotency-Key, and a second start is++ // a second stream.++ const call = ({ method = 'GET', ...opts }) => client.json({ ...at, method, ...(method === 'GET' ? {} : { idempotencyKey: false }), ...opts });++ const streamId = (v) => {++ const id = String(v || '');++ if (!AGENT_STREAM_ID.test(id)) throw new TypeError('streams: an agent stream id is ags_…');++ return id;++ };++ const text = (v, max, what) => {++ if (typeof v !== 'string' || !v.trim() || v.length > max) throw new TypeError(`streams: ${what} is 1–${max} characters`);++ return v.trim();++ };++ const oneOf = (v, allowed, what) => {++ if (!allowed.includes(v)) throw new TypeError(`streams: ${what} is ${allowed.join(', ')}`);++ return v;++ };++ const uri = (v, what) => {++ if (typeof v !== 'string' || !v.trim()) throw new TypeError(`streams: ${what} is a URI`);++ try { new URL(v); } catch { throw new TypeError(`streams: ${what} is a URI`); }++ return v;++ };++++ return {++ /**++ * Start an agent's stream → { stream, publish }: the stream (live.agent-stream@1) and the publish part++ * (live.agent-stream-publish@1) with the WHIP url, the publish ticket and the moment it dies. The ticket is++ * shown once; keep it, and renew it before it expires (it lives four hours).++ *++ * @param {{ title: string, task: { source: 'codes'|'actor'|'desk'|'node', ref: string, title: string, url?: string },++ * visibility: 'private'|'unlisted'|'public', allowSites?: string[] }} request++ * @param {{ signal?: AbortSignal }} [options]++ * @returns {Promise<{ stream: object, publish: { whip_url: string, ticket: string, expires_at: string } }>}++ */++ async startAgentStream(request = {}, { signal } = {}) {++ const r = request || {};++ const task = r.task || {};++ const body = {++ title: text(r.title, 200, 'a title'),++ task: {++ source: oneOf(task.source, TASK_SOURCES, 'the task source'),++ ref: text(task.ref, 200, 'a task reference'),++ title: text(task.title, 200, 'a task title'),++ ...(task.url ? { url: uri(task.url, 'the task URL') } : {}),++ },++ visibility: oneOf(r.visibility, VISIBILITIES, 'the visibility'),++ ...(r.allowSites === undefined ? {} : {++ allow_sites: (() => {++ if (!Array.isArray(r.allowSites) || r.allowSites.length > 50) throw new TypeError('streams: allowSites is an array of at most 50 URIs');++ return r.allowSites.map((site) => uri(site, 'an allowed site'));++ })(),++ }),++ };++ return call({ method: 'POST', path: '/api/streams/agent', json: body, signal });++ },++++ /**++ * A new publish ticket for a stream that is still live → the publish part again (the desk renews at three hours).++ * The old ticket stops working at once.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {{ signal?: AbortSignal }} [options]++ */++ async renewTicket(id, { signal } = {}) {++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });++ },++++ /**++ * Revoke the publish ticket: the kill switch. Live ends the WHIP session at once.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {{ signal?: AbortSignal }} [options]++ */++ async revokeTicket(id, { signal } = {}) {++ return call({ method: 'DELETE', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });++ },++++ /**++ * What the desk polls every ten seconds → { viewers, encode, curtain, ends_at }. While encode is false the++ * desk stops encoding and sends a snapshot every five seconds (postFrame).++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {{ ticket?: string, signal?: AbortSignal }} [options] the publish ticket, when the desk has one++ */++ async state(id, { ticket, signal } = {}) {++ return call({ method: 'GET', path: `/api/streams/agent/${streamId(id)}/state`, ...(ticket ? { token: ticket } : {}), signal });++ },++++ /**++ * Raise or drop the desk's privacy screen. While it is up Live pauses every viewer's consumer, so a desk that++ * failed to blank still shows nothing; viewers see "Private screen" and never why.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {boolean} up true to raise it++ * @param {'password_field'|'secret_file'|'env_print'|'manual'|'site'|'detector'} reason why++ * @param {{ signal?: AbortSignal }} [options]++ */++ async setCurtain(id, up, reason, { signal } = {}) {++ const body = { up: Boolean(up), reason: oneOf(reason, CURTAIN_REASONS, 'the curtain reason') };++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/curtain`, json: body, signal });++ },++++ /**++ * Send the last frame (JPEG or WebP, at most 200 KB): the card's picture and the task page's. The desk sends it++ * while Live wants no video.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {Uint8Array|ArrayBuffer} bytes the frame++ * @param {{ ticket?: string, contentType?: 'image/jpeg'|'image/webp', signal?: AbortSignal }} [options]++ */++ async postFrame(id, bytes, { ticket, contentType = 'image/jpeg', signal } = {}) {++ const body = frameBytes(bytes);++ if (!body.byteLength) throw new TypeError('streams: a frame is not empty');++ if (body.byteLength > MAX_FRAME_BYTES) throw new TypeError('streams: a frame is at most 200 KB');++ const res = await client.request({++ ...at, method: 'PUT', path: `/api/streams/agent/${streamId(id)}/frame`, body,++ headers: { 'Content-Type': oneOf(contentType, FRAME_TYPES, 'the frame type') },++ ...(ticket ? { token: ticket } : {}), idempotencyKey: false, signal,++ });++ return res.data;++ },++++ /**++ * Switch who may watch (the owner's switch): private, unlisted or public.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {'private'|'unlisted'|'public'} visibility++ * @param {{ signal?: AbortSignal }} [options]++ */++ async setVisibility(id, visibility, { signal } = {}) {++ const body = { visibility: oneOf(visibility, VISIBILITIES, 'the visibility') };++ return call({ method: 'PUT', path: `/api/streams/agent/${streamId(id)}/visibility`, json: body, signal });++ },++++ /**++ * End the stream. The card stays, with the last frame, the worked seconds and the VOD once it is recorded.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {{ signal?: AbortSignal }} [options]++ */++ async end(id, { signal } = {}) {++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/end`, signal });++ },++++ /**++ * A view ticket (wvt_…, ten minutes) for the player's join message. The owner's own session gets one, and so++ * does anyone the sharing system lets see a private stream.++ *++ * @param {string} id the agent stream id (ags_…)++ * @param {{ signal?: AbortSignal }} [options]++ */++ async viewTicket(id, { signal } = {}) {++ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/view-ticket`, signal });++ },++ };++}++++module.exports = { createStreamsClient };@@@ -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', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windowsfor (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`);@@@ -24,7 +24,7 @@ run([const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });const info = JSON.parse(out)[0];const files = info.files.map((f) => f.path);-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'src/streams.js', 'esm/streams.mjs', 'types/streams.d.ts', 'docs/streams.md', 'browser/openvibe-sdk.mjs']) {assert.ok(files.includes(must), `tarball contains ${must}`);}assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');@@@ -68,7 +68,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 83); // 0.89.0 adds openvibe-sdk/exchange and H1 adds openvibe-sdk/assertions++ assert.equal(subpaths.length, 84); // 0.90.0 adds openvibe-sdk/assertions and 0.91.0 openvibe-sdk/streamsassert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++// openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on Live. The routes, the bodies, the++// publish ticket as a bearer of its own on the desk's routes, the frame upload, and the guards the module sets before++// anything leaves the process.++const assert = require('assert');++const { createClient } = require('../src/core');++const { createStreamsClient } = require('../src/streams');++const { stubServer, send, run } = require('./helpers');++++const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';++++(async () => {++ const server = await stubServer(async (req, res, body) => {++ const path = req.url.split('?')[0];++ if (req.method === 'POST' && path === '/api/streams/agent') {++ return send(res, 201, {++ stream: { id: AGS, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', label: 'AI agent', state: 'idle' },++ publish: { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T16:00:00.000Z' },++ });++ }++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/ticket`) {++ return send(res, 201, { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T20:00:00.000Z' });++ }++ if (req.method === 'DELETE' && path === `/api/streams/agent/${AGS}/ticket`) return send(res, 200, { revoked: true });++ if (req.method === 'GET' && path === `/api/streams/agent/${AGS}/state`) {++ return send(res, 200, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });++ }++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/curtain`) return send(res, 200, { ok: true });++ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/frame`) return send(res, 200, { ok: true });++ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/visibility`) return send(res, 200, { ok: true });++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/end`) return send(res, 200, { ok: true });++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/view-ticket`) {++ return send(res, 201, { ticket: 'wvt_0123456789abcdefghijklmnop', expires_at: '2026-11-06T12:10:00.000Z' });++ }++ return send(res, 404, { error: 'not found', code: 'live.not_found' });++ });++ const client = createClient({ baseUrls: { live: server.url }, token: 'person-token', retries: 0 });++ const streams = createStreamsClient(client);++ const last = () => server.requests[server.requests.length - 1];++++ await run([++ ['startAgentStream posts the create request and answers the stream and the publish part', async () => {++ const r = await streams.startAgentStream({++ title: 'Fixing the login page',++ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },++ visibility: 'private',++ allowSites: ['https://example.com'],++ });++ assert.strictEqual(last().method, 'POST');++ assert.strictEqual(last().url, '/api/streams/agent');++ assert.deepStrictEqual(JSON.parse(String(last().body)), {++ title: 'Fixing the login page',++ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },++ visibility: 'private',++ allow_sites: ['https://example.com'],++ });++ assert.strictEqual(last().headers.authorization, 'Bearer person-token', "the person's token rides along");++ assert.strictEqual(r.stream.id, AGS);++ assert.strictEqual(r.publish.ticket, TICKET);++ }],++++ ['the guards run before anything leaves', async () => {++ const before = server.requests.length;++ await assert.rejects(() => streams.startAgentStream({ title: '', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private' }), /title/);++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'browser', ref: 'a', title: 'b' }, visibility: 'private' }), /task source/);++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'everyone' }), /visibility/);++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b', url: '/relative/path' }, visibility: 'private' }), /task URL/);++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: 'https://example.com' }), /allowSites/);++ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: ['not a URI'] }), /allowed site/);++ await assert.rejects(() => streams.state('stream_77'), /agent stream id/);++ await assert.rejects(() => streams.setCurtain(AGS, true, 'gossip'), /curtain reason/);++ await assert.rejects(() => streams.setVisibility(AGS, 'everyone'), /visibility/);++ await assert.rejects(() => streams.postFrame(AGS, new Uint8Array(200 * 1024 + 1)), /200 KB/);++ assert.strictEqual(server.requests.length, before, 'nothing was sent');++ }],++++ ['the ticket routes: renew, revoke', async () => {++ await streams.renewTicket(AGS);++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/ticket`]);++ await streams.revokeTicket(AGS);++ assert.deepStrictEqual([last().method, last().url], ['DELETE', `/api/streams/agent/${AGS}/ticket`]);++ }],++++ ["the desk's routes carry the publish ticket, not the person's token", async () => {++ await streams.state(AGS, { ticket: TICKET });++ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);++ await streams.state(AGS);++ assert.strictEqual(last().headers.authorization, 'Bearer person-token', 'without a ticket the person\'s token is used');++ }],++++ ['the state poll answers viewers, encode, curtain and ends_at', async () => {++ const s = await streams.state(AGS, { ticket: TICKET });++ assert.deepStrictEqual(s, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });++ }],++++ ['the curtain goes up and comes down', async () => {++ await streams.setCurtain(AGS, true, 'password_field');++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/curtain`]);++ assert.deepStrictEqual(JSON.parse(String(last().body)), { up: true, reason: 'password_field' });++ }],++++ ['a frame goes up as bytes with its own type', async () => {++ const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);++ await streams.postFrame(AGS, jpeg, { ticket: TICKET });++ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/frame`]);++ assert.strictEqual(last().headers['content-type'], 'image/jpeg');++ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);++ assert.deepStrictEqual([...last().body], [...jpeg]);++ await streams.postFrame(AGS, jpeg, { contentType: 'image/webp' });++ assert.strictEqual(last().headers['content-type'], 'image/webp');++ }],++++ ['visibility, end and the view ticket', async () => {++ await streams.setVisibility(AGS, 'unlisted');++ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/visibility`]);++ assert.deepStrictEqual(JSON.parse(String(last().body)), { visibility: 'unlisted' });++ await streams.end(AGS);++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/end`]);++ const v = await streams.viewTicket(AGS);++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/view-ticket`]);++ assert.ok(v.ticket.startsWith('wvt_'));++ }],++ ]);++++ await server.close();++ console.log('openvibe-sdk/streams: the agent-stream routes, the ticket as a bearer, the frame upload and the guards');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -74,7 +74,7 @@ export type ServiceTokenClaims = {*/env?: "sandbox" | "production";/**-- * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Absent on client_credentials tokens.++ * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Also present on a node token that asks for an audience where the machine acts for its owner (Agent Streams S1: openvibe.live and live.agent_stream.publish); absent on every other client_credentials token.*/on_behalf_of?: string;/**nnew file mode 100644@@@ -0,0 +1,69 @@++/** openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on OpenVibe.Live. */++type Opts = { signal?: AbortSignal };++/** Who may watch an agent stream. */++export type AgentStreamVisibility = 'private' | 'unlisted' | 'public';++/** Where the agent's task runs. */++export type AgentTaskSource = 'codes' | 'actor' | 'desk' | 'node';++/** Why the desk's privacy screen is up. */++export type CurtainReason = 'password_field' | 'secret_file' | 'env_print' | 'manual' | 'site' | 'detector';++export interface AgentTask {++ source: AgentTaskSource;++ ref: string;++ title: string;++ url?: string;++}++export interface AgentStream {++ id: string;++ owner: string;++ channel: string;++ title: string;++ task: AgentTask;++ visibility: AgentStreamVisibility;++ mode: 'watch' | 'narrated' | 'copilot' | 'chatplays';++ state: 'idle' | 'live' | 'ended';++ curtain: { up: boolean; since?: string | null };++ viewers: number;++ started_at: string | null;++ ended_at: string | null;++ worked_seconds: number;++ last_frame_url: string | null;++ vod_id: string | null;++ page_url: string;++ label: 'AI agent';++}++/** The publish part: where to publish, with the ticket that opens the door (shown once). */++export interface AgentStreamPublish { whip_url: string; ticket: string; expires_at: string }++/** What the desk polls: viewers, whether Live wants video, the privacy screen, when it ends. */++export interface AgentStreamState {++ viewers: number;++ encode: boolean;++ curtain: { up: boolean; since?: string | null; reason?: CurtainReason };++ ends_at: string | null;++}++export interface StartAgentStreamRequest {++ title: string;++ task: AgentTask;++ visibility: AgentStreamVisibility;++ allowSites?: string[];++}++export interface StreamsClient {++ /** Start an agent's stream → { stream, publish }. */++ startAgentStream(request: StartAgentStreamRequest, opts?: Opts): Promise<{ stream: AgentStream; publish: AgentStreamPublish }>;++ /** A new publish ticket for a stream that is still live. */++ renewTicket(id: string, opts?: Opts): Promise<AgentStreamPublish>;++ /** Revoke the publish ticket: the kill switch. */++ revokeTicket(id: string, opts?: Opts): Promise<Record<string, unknown>>;++ /** What the desk polls every ten seconds (with the publish ticket). */++ state(id: string, opts?: Opts & { ticket?: string }): Promise<AgentStreamState>;++ /** Raise or drop the desk's privacy screen. */++ setCurtain(id: string, up: boolean, reason: CurtainReason, opts?: Opts): Promise<Record<string, unknown>>;++ /** Send the last frame (JPEG or WebP, at most 200 KB). */++ postFrame(id: string, bytes: Uint8Array | ArrayBuffer, opts?: Opts & { ticket?: string; contentType?: 'image/jpeg' | 'image/webp' }): Promise<Record<string, unknown>>;++ /** Switch who may watch. */++ setVisibility(id: string, visibility: AgentStreamVisibility, opts?: Opts): Promise<Record<string, unknown>>;++ /** End the stream; the card stays. */++ end(id: string, opts?: Opts): Promise<Record<string, unknown>>;++ /** A view ticket for the player's join message (ten minutes). */++ viewTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string }>;++}++export function createStreamsClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any>; origin(service: string): Promise<string> }, options?: { baseUrl?: string }): StreamsClient;
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
# Changelog
# Changelog
+## 0.91.0 (2026-11-06)
+
+- `openvibe-sdk/streams`: the Agent Streams plan, stage 1 — an agent's own stream on OpenVibe.Live (a stream of kind `agent`, id `ags_…`, no stream key and no slot, published with a ticket instead of a key).
+ - `startAgentStream({ title, task, visibility, allowSites })` answers `{ stream, publish }`: the stream (live.agent-stream@1) and the publish part (live.agent-stream-publish@1) with the WHIP url, the publish ticket (shown once; Live keeps only its hash) and the moment it dies.
+ - `renewTicket(id)` and `revokeTicket(id)`: the ticket lives four hours (the desk renews it at three) and revoking it is the kill switch — Live ends the WHIP session at once.
+ - `state(id, { ticket })` is what the desk polls every ten seconds: `{ viewers, encode, curtain, ends_at }`. While `encode` is false the desk stops encoding and sends a snapshot every five seconds with `postFrame(id, bytes, { ticket, contentType })` (JPEG or WebP, at most 200 KB).
+ - `setCurtain(id, up, reason)` raises the desk's privacy screen (while it is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why), `setVisibility(id, v)` switches who may watch, `end(id)` ends the stream (the card stays, with the last frame and the VOD) and `viewTicket(id)` mints the player's view ticket (ten minutes).
+ - The publish ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket, never the person's token. Every body is checked in the module before it leaves, and a bad id, visibility, curtain reason or an oversized frame is refused with a TypeError.
+ - The capability behind it is `live.agent_stream.publish` for the audience `openvibe.live` (Contracts 0.298.0): a Node principal acting for its owner, Codes or Actor. Never a developer-app token, never another person's stream.
+
## 0.90.0 (2026-11-06)
## 0.90.0 (2026-11-06)
- **Subject assertions** (ADR-003, the 2026-11-06 amendment; plan H1). A money call carries a Network-signed
- **Subject assertions** (ADR-003, the 2026-11-06 amendment; plan H1). A money call carries a Network-signed
@@ -2,16 +2,16 @@
@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.90.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.91.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
-After v0.90.0 is tagged:
+After v0.91.0 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@ -195,6 +195,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -195,6 +195,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `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/streams` | server | `createStreamsClient(client, {baseUrl?})`: an agent's own stream on OpenVibe.Live (the Agent Streams plan, S1; `live.agent-stream@1`, an `ags_…` id with no stream key and no slot). `startAgentStream({title, task, visibility, allowSites})` → `{stream, publish}` (the WHIP url and the one-time `wpt_…` ticket with its expiry), `renewTicket(id)` and `revokeTicket(id)` (the kill switch: Live ends the WHIP session at once), `state(id, {ticket})` (what the desk polls: viewers, `encode`, the curtain, `ends_at`), `postFrame(id, bytes, {ticket, contentType})` (the last frame while Live wants no video, JPEG or WebP at most 200 KB), `setCurtain(id, up, reason)`, `setVisibility(id, v)`, `end(id)` and `viewTicket(id)`. The publish ticket is a bearer of its own on the desk routes; a bad id, visibility, curtain reason or an oversized frame is refused before anything leaves. [Docs](docs/streams.md) |
| `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/blog` | server | `createBlogClient(client)`: OpenVibe.Blog for the person who authorized the app. `blogs.get/posts(handle, {limit, offset, all})/feed` (JSON Feed), `posts.create(handle, {title, body, summary, visibility, …})` → `{ post, revision }`, `posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n)`. Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience `openvibe.blog` |
| `openvibe-sdk/blog` | server | `createBlogClient(client)`: OpenVibe.Blog for the person who authorized the app. `blogs.get/posts(handle, {limit, offset, all})/feed` (JSON Feed), `posts.create(handle, {title, body, summary, visibility, …})` → `{ post, revision }`, `posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n)`. Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience `openvibe.blog` |
@@ -3,11 +3,11 @@
@@ -3,11 +3,11 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.90.0",
+ "version": "0.91.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",
- "contracts": "openvibe-contracts v0.297.0",
+ "contracts": "openvibe-contracts v0.298.0",
"features": [
"features": [
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
@@ -41,7 +41,8 @@
@@ -41,7 +41,8 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"
+ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps",
+ "streams (openvibe-sdk/streams): the Agent Streams plan S1 — start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"
],
],
"updated": "2026-11-06"
"updated": "2026-11-06"
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.90.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.91.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, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,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.90.0';
+const SDK_VERSION = '0.91.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -953,6 +953,9 @@
@@ -953,6 +953,9 @@
"storageOffer": "function",
"storageOffer": "function",
"validateStorageOffer": "function"
"validateStorageOffer": "function"
},
},
+ "openvibe-sdk/streams": {
+ "createStreamsClient": "function"
+ },
"openvibe-sdk/telemetry": {
"openvibe-sdk/telemetry": {
"createPgTelemetrySink": "function",
"createPgTelemetrySink": "function",
"createTelemetry": "function",
"createTelemetry": "function",
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+# The Agent Streams (S1)
+
+`openvibe-sdk/streams` drives an agent's own stream on OpenVibe.Live: the Agent Streams plan, stage 1.
+
+An agent stream is a Live stream of kind `agent` (its id starts `ags_`). It never has a stream key and never takes a
+normal slot; it is published with a **publish ticket** instead, and the ticket is what opens the door.
+
+```js
+const { createClient } = require('openvibe-sdk/core');
+const { createStreamsClient } = require('openvibe-sdk/streams');
+
+const streams = createStreamsClient(createClient({ tokenProvider }));
+
+const { stream, publish } = await streams.startAgentStream({
+ title: 'Fixing the login page',
+ task: { source: 'codes', ref: 'task_01JAB…', title: 'Fix the login page' },
+ visibility: 'private',
+});
+// publish.whip_url, publish.ticket (shown once), publish.expires_at
+```
+
+## The desk's loop
+
+The desk (OpenVibe.Node) polls Live every ten seconds and keeps publishing while Live wants video:
+
+```js
+const s = await streams.state(stream.id, { ticket: publish.ticket });
+// { viewers, encode, curtain: { up }, ends_at }
+
+if (!s.encode) await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket });
+```
+
+While `encode` is false the desk stops encoding and sends a snapshot every five seconds (JPEG or WebP, at most
+200 KB). While the curtain is up Live pauses every viewer's consumer, so a viewer sees "Private screen" and never why.
+
+## The privacy screen
+
+```js
+await streams.setCurtain(stream.id, true, 'password_field'); // up: a password field is focused
+await streams.setCurtain(stream.id, false, 'password_field'); // down: the detectors are clear again
+```
+
+The curtain comes down only from the desk (after its detectors report clear for two seconds) or from the owner's own
+button. A detector that fails or is unsure puts the curtain up and never down. The reason is the owner's only.
+
+## The ticket
+
+The ticket lives four hours; the desk renews it at three.
+
+```js
+const again = await streams.renewTicket(stream.id); // a new ticket; the old one stops working
+await streams.revokeTicket(stream.id); // the kill switch: Live ends the WHIP session at once
+```
+
+The ticket is a bearer of its own: on the desk's routes (`state`, `postFrame`) the desk sends the ticket instead of
+the person's token, so Live never sees the person's token there.
+
+## Watching
+
+```js
+await streams.setVisibility(stream.id, 'unlisted'); // private | unlisted | public
+const { ticket: view } = await streams.viewTicket(stream.id); // wvt_…, ten minutes
+await streams.end(stream.id); // the card stays, with the last frame and the VOD
+```
+
+## Who may call
+
+The person's own session token, or a first-party service token acting for that person: a Node principal acting for its
+owner (Network issues the node token with `live.agent_stream.publish` for the audience `openvibe.live` and names its
+owner in `on_behalf_of`), Codes or Actor. Never a developer-app token, never another person's stream.
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/streams.js. Do not edit.
+import cjs from '../src/streams.js';
+
+export const {
+ createStreamsClient,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.90.0",
+ "version": "0.91.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.90.0",
+ "version": "0.91.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -198,9 +198,9 @@
@@ -198,9 +198,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.297.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
- "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
+ "version": "0.298.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
+ "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
"dev": true,
"dev": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.90.0",
+ "version": "0.91.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",
@@ -299,6 +299,12 @@
@@ -299,6 +299,12 @@
"import": "./esm/live.mjs",
"import": "./esm/live.mjs",
"default": "./src/live.js"
"default": "./src/live.js"
},
},
+ "./streams": {
+ "types": "./types/streams.d.ts",
+ "browser": null,
+ "import": "./esm/streams.mjs",
+ "default": "./src/streams.js"
+ },
"./inventory": {
"./inventory": {
"types": "./types/inventory.d.ts",
"types": "./types/inventory.d.ts",
"browser": null,
"browser": null,
@@ -526,7 +532,7 @@
@@ -526,7 +532,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"peerDependencies": {
"peerDependencies": {
@@ -68,6 +68,7 @@ const ENTRIES = {
@@ -68,6 +68,7 @@ const ENTRIES = {
'download.mjs': '../src/download.js',
'download.mjs': '../src/download.js',
'quest.mjs': '../src/quest.js',
'quest.mjs': '../src/quest.js',
'live.mjs': '../src/live.js',
'live.mjs': '../src/live.js',
+ 'streams.mjs': '../src/streams.js',
'inventory.mjs': '../src/inventory.js',
'inventory.mjs': '../src/inventory.js',
'tips.mjs': '../src/tips.js',
'tips.mjs': '../src/tips.js',
'blog.mjs': '../src/blog.js',
'blog.mjs': '../src/blog.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.90.0';
+const SDK_VERSION = '0.91.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
+'use strict';
+/**
+ * openvibe-sdk/streams — the Agent Streams plan (S1): an agent's own stream on OpenVibe.Live.
+ *
+ * An agent stream is a Live stream of kind 'agent' (id ags_…): it has no stream key and takes no slot, and it is
+ * published with a ticket (wpt_…) instead of one. The desk (OpenVibe.Node) or a person's browser (a Codes task page)
+ * creates it, keeps the ticket and publishes through it; the owner watches the stream on the page Live answers with.
+ *
+ * const streams = createStreamsClient(createClient({ tokenProvider }));
+ * const { stream, publish } = await streams.startAgentStream({
+ * title: 'Fixing the login page',
+ * task: { source: 'codes', ref: 'task_…', title: 'Fix the login page' },
+ * visibility: 'private',
+ * });
+ * await streams.postFrame(stream.id, jpegBytes, { ticket: publish.ticket }); // a snapshot while Live wants no video
+ * const s = await streams.state(stream.id, { ticket: publish.ticket }); // { viewers, encode, curtain, ends_at }
+ * await streams.setCurtain(stream.id, true, 'password_field'); // the desk's privacy screen
+ * await streams.renewTicket(stream.id); // the ticket lives four hours
+ * await streams.end(stream.id);
+ *
+ * The publish ticket is a bearer of its own: on the desk's routes (the state poll and the frame upload) the desk sends
+ * the ticket instead of the person's token, so Live never sees the person's token there. The ticket is shown once and
+ * Live keeps only its hash.
+ */
+const AGENT_STREAM_ID = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
+const VISIBILITIES = ['private', 'unlisted', 'public'];
+const TASK_SOURCES = ['codes', 'actor', 'desk', 'node'];
+const CURTAIN_REASONS = ['password_field', 'secret_file', 'env_print', 'manual', 'site', 'detector'];
+const FRAME_TYPES = ['image/jpeg', 'image/webp'];
+const MAX_FRAME_BYTES = 200 * 1024;
+
+/** The frame's bytes, whatever the caller has: a Uint8Array, a Buffer or an ArrayBuffer. */
+function frameBytes(v) {
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
+ if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
+ throw new TypeError('streams: a frame is bytes (a Uint8Array or an ArrayBuffer)');
+}
+
+/**
+ * createStreamsClient(client, { baseUrl }) — the agent-stream routes of OpenVibe.Live.
+ * The client is an openvibe-sdk/core client; its token is the person's (or the service's) token.
+ */
+function createStreamsClient(client, { baseUrl } = {}) {
+ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createStreamsClient: pass an openvibe-sdk/core client');
+ const at = { service: 'live', baseUrl, audience: 'openvibe.live' };
+ // A write is never retried on its own: Live keys no agent-stream route by Idempotency-Key, and a second start is
+ // a second stream.
+ const call = ({ method = 'GET', ...opts }) => client.json({ ...at, method, ...(method === 'GET' ? {} : { idempotencyKey: false }), ...opts });
+ const streamId = (v) => {
+ const id = String(v || '');
+ if (!AGENT_STREAM_ID.test(id)) throw new TypeError('streams: an agent stream id is ags_…');
+ return id;
+ };
+ const text = (v, max, what) => {
+ if (typeof v !== 'string' || !v.trim() || v.length > max) throw new TypeError(`streams: ${what} is 1–${max} characters`);
+ return v.trim();
+ };
+ const oneOf = (v, allowed, what) => {
+ if (!allowed.includes(v)) throw new TypeError(`streams: ${what} is ${allowed.join(', ')}`);
+ return v;
+ };
+ const uri = (v, what) => {
+ if (typeof v !== 'string' || !v.trim()) throw new TypeError(`streams: ${what} is a URI`);
+ try { new URL(v); } catch { throw new TypeError(`streams: ${what} is a URI`); }
+ return v;
+ };
+
+ return {
+ /**
+ * Start an agent's stream → { stream, publish }: the stream (live.agent-stream@1) and the publish part
+ * (live.agent-stream-publish@1) with the WHIP url, the publish ticket and the moment it dies. The ticket is
+ * shown once; keep it, and renew it before it expires (it lives four hours).
+ *
+ * @param {{ title: string, task: { source: 'codes'|'actor'|'desk'|'node', ref: string, title: string, url?: string },
+ * visibility: 'private'|'unlisted'|'public', allowSites?: string[] }} request
+ * @param {{ signal?: AbortSignal }} [options]
+ * @returns {Promise<{ stream: object, publish: { whip_url: string, ticket: string, expires_at: string } }>}
+ */
+ async startAgentStream(request = {}, { signal } = {}) {
+ const r = request || {};
+ const task = r.task || {};
+ const body = {
+ title: text(r.title, 200, 'a title'),
+ task: {
+ source: oneOf(task.source, TASK_SOURCES, 'the task source'),
+ ref: text(task.ref, 200, 'a task reference'),
+ title: text(task.title, 200, 'a task title'),
+ ...(task.url ? { url: uri(task.url, 'the task URL') } : {}),
+ },
+ visibility: oneOf(r.visibility, VISIBILITIES, 'the visibility'),
+ ...(r.allowSites === undefined ? {} : {
+ allow_sites: (() => {
+ if (!Array.isArray(r.allowSites) || r.allowSites.length > 50) throw new TypeError('streams: allowSites is an array of at most 50 URIs');
+ return r.allowSites.map((site) => uri(site, 'an allowed site'));
+ })(),
+ }),
+ };
+ return call({ method: 'POST', path: '/api/streams/agent', json: body, signal });
+ },
+
+ /**
+ * A new publish ticket for a stream that is still live → the publish part again (the desk renews at three hours).
+ * The old ticket stops working at once.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async renewTicket(id, { signal } = {}) {
+ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });
+ },
+
+ /**
+ * Revoke the publish ticket: the kill switch. Live ends the WHIP session at once.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async revokeTicket(id, { signal } = {}) {
+ return call({ method: 'DELETE', path: `/api/streams/agent/${streamId(id)}/ticket`, signal });
+ },
+
+ /**
+ * What the desk polls every ten seconds → { viewers, encode, curtain, ends_at }. While encode is false the
+ * desk stops encoding and sends a snapshot every five seconds (postFrame).
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {{ ticket?: string, signal?: AbortSignal }} [options] the publish ticket, when the desk has one
+ */
+ async state(id, { ticket, signal } = {}) {
+ return call({ method: 'GET', path: `/api/streams/agent/${streamId(id)}/state`, ...(ticket ? { token: ticket } : {}), signal });
+ },
+
+ /**
+ * Raise or drop the desk's privacy screen. While it is up Live pauses every viewer's consumer, so a desk that
+ * failed to blank still shows nothing; viewers see "Private screen" and never why.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {boolean} up true to raise it
+ * @param {'password_field'|'secret_file'|'env_print'|'manual'|'site'|'detector'} reason why
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async setCurtain(id, up, reason, { signal } = {}) {
+ const body = { up: Boolean(up), reason: oneOf(reason, CURTAIN_REASONS, 'the curtain reason') };
+ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/curtain`, json: body, signal });
+ },
+
+ /**
+ * Send the last frame (JPEG or WebP, at most 200 KB): the card's picture and the task page's. The desk sends it
+ * while Live wants no video.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {Uint8Array|ArrayBuffer} bytes the frame
+ * @param {{ ticket?: string, contentType?: 'image/jpeg'|'image/webp', signal?: AbortSignal }} [options]
+ */
+ async postFrame(id, bytes, { ticket, contentType = 'image/jpeg', signal } = {}) {
+ const body = frameBytes(bytes);
+ if (!body.byteLength) throw new TypeError('streams: a frame is not empty');
+ if (body.byteLength > MAX_FRAME_BYTES) throw new TypeError('streams: a frame is at most 200 KB');
+ const res = await client.request({
+ ...at, method: 'PUT', path: `/api/streams/agent/${streamId(id)}/frame`, body,
+ headers: { 'Content-Type': oneOf(contentType, FRAME_TYPES, 'the frame type') },
+ ...(ticket ? { token: ticket } : {}), idempotencyKey: false, signal,
+ });
+ return res.data;
+ },
+
+ /**
+ * Switch who may watch (the owner's switch): private, unlisted or public.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {'private'|'unlisted'|'public'} visibility
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async setVisibility(id, visibility, { signal } = {}) {
+ const body = { visibility: oneOf(visibility, VISIBILITIES, 'the visibility') };
+ return call({ method: 'PUT', path: `/api/streams/agent/${streamId(id)}/visibility`, json: body, signal });
+ },
+
+ /**
+ * End the stream. The card stays, with the last frame, the worked seconds and the VOD once it is recorded.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async end(id, { signal } = {}) {
+ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/end`, signal });
+ },
+
+ /**
+ * A view ticket (wvt_…, ten minutes) for the player's join message. The owner's own session gets one, and so
+ * does anyone the sharing system lets see a private stream.
+ *
+ * @param {string} id the agent stream id (ags_…)
+ * @param {{ signal?: AbortSignal }} [options]
+ */
+ async viewTicket(id, { signal } = {}) {
+ return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/view-ticket`, signal });
+ },
+ };
+}
+
+module.exports = { createStreamsClient };
@@ -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', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
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`);
@@ -24,7 +24,7 @@ run([
@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
const files = info.files.map((f) => f.path);
- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {
+ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'src/streams.js', 'esm/streams.mjs', 'types/streams.d.ts', 'docs/streams.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
assert.ok(files.includes(must), `tarball contains ${must}`);
}
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@ -68,7 +68,7 @@ run([
@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 83); // 0.89.0 adds openvibe-sdk/exchange and H1 adds openvibe-sdk/assertions
+ assert.equal(subpaths.length, 84); // 0.90.0 adds openvibe-sdk/assertions and 0.91.0 openvibe-sdk/streams
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+// openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on Live. The routes, the bodies, the
+// publish ticket as a bearer of its own on the desk's routes, the frame upload, and the guards the module sets before
+// anything leaves the process.
+const assert = require('assert');
+const { createClient } = require('../src/core');
+const { createStreamsClient } = require('../src/streams');
+const { stubServer, send, run } = require('./helpers');
+
+const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
+
+(async () => {
+ const server = await stubServer(async (req, res, body) => {
+ const path = req.url.split('?')[0];
+ if (req.method === 'POST' && path === '/api/streams/agent') {
+ return send(res, 201, {
+ stream: { id: AGS, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', label: 'AI agent', state: 'idle' },
+ publish: { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T16:00:00.000Z' },
+ });
+ }
+ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/ticket`) {
+ return send(res, 201, { whip_url: `https://whip.openvibe.live/whip/agent/${AGS}`, ticket: TICKET, expires_at: '2026-11-06T20:00:00.000Z' });
+ }
+ if (req.method === 'DELETE' && path === `/api/streams/agent/${AGS}/ticket`) return send(res, 200, { revoked: true });
+ if (req.method === 'GET' && path === `/api/streams/agent/${AGS}/state`) {
+ return send(res, 200, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });
+ }
+ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/curtain`) return send(res, 200, { ok: true });
+ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/frame`) return send(res, 200, { ok: true });
+ if (req.method === 'PUT' && path === `/api/streams/agent/${AGS}/visibility`) return send(res, 200, { ok: true });
+ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/end`) return send(res, 200, { ok: true });
+ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/view-ticket`) {
+ return send(res, 201, { ticket: 'wvt_0123456789abcdefghijklmnop', expires_at: '2026-11-06T12:10:00.000Z' });
+ }
+ return send(res, 404, { error: 'not found', code: 'live.not_found' });
+ });
+ const client = createClient({ baseUrls: { live: server.url }, token: 'person-token', retries: 0 });
+ const streams = createStreamsClient(client);
+ const last = () => server.requests[server.requests.length - 1];
+
+ await run([
+ ['startAgentStream posts the create request and answers the stream and the publish part', async () => {
+ const r = await streams.startAgentStream({
+ title: 'Fixing the login page',
+ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },
+ visibility: 'private',
+ allowSites: ['https://example.com'],
+ });
+ assert.strictEqual(last().method, 'POST');
+ assert.strictEqual(last().url, '/api/streams/agent');
+ assert.deepStrictEqual(JSON.parse(String(last().body)), {
+ title: 'Fixing the login page',
+ task: { source: 'codes', ref: 'task_01JAB', title: 'Fix the login page', url: 'https://codes.openvibe.live/tasks/task_01JAB' },
+ visibility: 'private',
+ allow_sites: ['https://example.com'],
+ });
+ assert.strictEqual(last().headers.authorization, 'Bearer person-token', "the person's token rides along");
+ assert.strictEqual(r.stream.id, AGS);
+ assert.strictEqual(r.publish.ticket, TICKET);
+ }],
+
+ ['the guards run before anything leaves', async () => {
+ const before = server.requests.length;
+ await assert.rejects(() => streams.startAgentStream({ title: '', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private' }), /title/);
+ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'browser', ref: 'a', title: 'b' }, visibility: 'private' }), /task source/);
+ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'everyone' }), /visibility/);
+ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b', url: '/relative/path' }, visibility: 'private' }), /task URL/);
+ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: 'https://example.com' }), /allowSites/);
+ await assert.rejects(() => streams.startAgentStream({ title: 'x', task: { source: 'codes', ref: 'a', title: 'b' }, visibility: 'private', allowSites: ['not a URI'] }), /allowed site/);
+ await assert.rejects(() => streams.state('stream_77'), /agent stream id/);
+ await assert.rejects(() => streams.setCurtain(AGS, true, 'gossip'), /curtain reason/);
+ await assert.rejects(() => streams.setVisibility(AGS, 'everyone'), /visibility/);
+ await assert.rejects(() => streams.postFrame(AGS, new Uint8Array(200 * 1024 + 1)), /200 KB/);
+ assert.strictEqual(server.requests.length, before, 'nothing was sent');
+ }],
+
+ ['the ticket routes: renew, revoke', async () => {
+ await streams.renewTicket(AGS);
+ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/ticket`]);
+ await streams.revokeTicket(AGS);
+ assert.deepStrictEqual([last().method, last().url], ['DELETE', `/api/streams/agent/${AGS}/ticket`]);
+ }],
+
+ ["the desk's routes carry the publish ticket, not the person's token", async () => {
+ await streams.state(AGS, { ticket: TICKET });
+ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);
+ await streams.state(AGS);
+ assert.strictEqual(last().headers.authorization, 'Bearer person-token', 'without a ticket the person\'s token is used');
+ }],
+
+ ['the state poll answers viewers, encode, curtain and ends_at', async () => {
+ const s = await streams.state(AGS, { ticket: TICKET });
+ assert.deepStrictEqual(s, { viewers: 2, encode: false, curtain: { up: false }, ends_at: null });
+ }],
+
+ ['the curtain goes up and comes down', async () => {
+ await streams.setCurtain(AGS, true, 'password_field');
+ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/curtain`]);
+ assert.deepStrictEqual(JSON.parse(String(last().body)), { up: true, reason: 'password_field' });
+ }],
+
+ ['a frame goes up as bytes with its own type', async () => {
+ const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]);
+ await streams.postFrame(AGS, jpeg, { ticket: TICKET });
+ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/frame`]);
+ assert.strictEqual(last().headers['content-type'], 'image/jpeg');
+ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);
+ assert.deepStrictEqual([...last().body], [...jpeg]);
+ await streams.postFrame(AGS, jpeg, { contentType: 'image/webp' });
+ assert.strictEqual(last().headers['content-type'], 'image/webp');
+ }],
+
+ ['visibility, end and the view ticket', async () => {
+ await streams.setVisibility(AGS, 'unlisted');
+ assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/visibility`]);
+ assert.deepStrictEqual(JSON.parse(String(last().body)), { visibility: 'unlisted' });
+ await streams.end(AGS);
+ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/end`]);
+ const v = await streams.viewTicket(AGS);
+ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/view-ticket`]);
+ assert.ok(v.ticket.startsWith('wvt_'));
+ }],
+ ]);
+
+ await server.close();
+ console.log('openvibe-sdk/streams: the agent-stream routes, the ticket as a bearer, the frame upload and the guards');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -74,7 +74,7 @@ export type ServiceTokenClaims = {
@@ -74,7 +74,7 @@ export type ServiceTokenClaims = {
*/
*/
env?: "sandbox" | "production";
env?: "sandbox" | "production";
/**
/**
- * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Absent on client_credentials tokens.
+ * The person who authorized an app through the authorization-code flow, or the owner an agent acts for (always present on agent tokens). Also present on a node token that asks for an audience where the machine acts for its owner (Agent Streams S1: openvibe.live and live.agent_stream.publish); absent on every other client_credentials token.
*/
*/
on_behalf_of?: string;
on_behalf_of?: string;
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+/** openvibe-sdk/streams: the Agent Streams plan (S1) — an agent's own stream on OpenVibe.Live. */
+type Opts = { signal?: AbortSignal };
+/** Who may watch an agent stream. */
+export type AgentStreamVisibility = 'private' | 'unlisted' | 'public';
+/** Where the agent's task runs. */
+export type AgentTaskSource = 'codes' | 'actor' | 'desk' | 'node';
+/** Why the desk's privacy screen is up. */
+export type CurtainReason = 'password_field' | 'secret_file' | 'env_print' | 'manual' | 'site' | 'detector';
+export interface AgentTask {
+ source: AgentTaskSource;
+ ref: string;
+ title: string;
+ url?: string;
+}
+export interface AgentStream {
+ id: string;
+ owner: string;
+ channel: string;
+ title: string;
+ task: AgentTask;
+ visibility: AgentStreamVisibility;
+ mode: 'watch' | 'narrated' | 'copilot' | 'chatplays';
+ state: 'idle' | 'live' | 'ended';
+ curtain: { up: boolean; since?: string | null };
+ viewers: number;
+ started_at: string | null;
+ ended_at: string | null;
+ worked_seconds: number;
+ last_frame_url: string | null;
+ vod_id: string | null;
+ page_url: string;
+ label: 'AI agent';
+}
+/** The publish part: where to publish, with the ticket that opens the door (shown once). */
+export interface AgentStreamPublish { whip_url: string; ticket: string; expires_at: string }
+/** What the desk polls: viewers, whether Live wants video, the privacy screen, when it ends. */
+export interface AgentStreamState {
+ viewers: number;
+ encode: boolean;
+ curtain: { up: boolean; since?: string | null; reason?: CurtainReason };
+ ends_at: string | null;
+}
+export interface StartAgentStreamRequest {
+ title: string;
+ task: AgentTask;
+ visibility: AgentStreamVisibility;
+ allowSites?: string[];
+}
+export interface StreamsClient {
+ /** Start an agent's stream → { stream, publish }. */
+ startAgentStream(request: StartAgentStreamRequest, opts?: Opts): Promise<{ stream: AgentStream; publish: AgentStreamPublish }>;
+ /** A new publish ticket for a stream that is still live. */
+ renewTicket(id: string, opts?: Opts): Promise<AgentStreamPublish>;
+ /** Revoke the publish ticket: the kill switch. */
+ revokeTicket(id: string, opts?: Opts): Promise<Record<string, unknown>>;
+ /** What the desk polls every ten seconds (with the publish ticket). */
+ state(id: string, opts?: Opts & { ticket?: string }): Promise<AgentStreamState>;
+ /** Raise or drop the desk's privacy screen. */
+ setCurtain(id: string, up: boolean, reason: CurtainReason, opts?: Opts): Promise<Record<string, unknown>>;
+ /** Send the last frame (JPEG or WebP, at most 200 KB). */
+ postFrame(id: string, bytes: Uint8Array | ArrayBuffer, opts?: Opts & { ticket?: string; contentType?: 'image/jpeg' | 'image/webp' }): Promise<Record<string, unknown>>;
+ /** Switch who may watch. */
+ setVisibility(id: string, visibility: AgentStreamVisibility, opts?: Opts): Promise<Record<string, unknown>>;
+ /** End the stream; the card stays. */
+ end(id: string, opts?: Opts): Promise<Record<string, unknown>>;
+ /** A view ticket for the player's join message (ten minutes). */
+ viewTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string }>;
+}
+export function createStreamsClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any>; origin(service: string): Promise<string> }, options?: { baseUrl?: string }): StreamsClient;