openvibers/openvibe.sdk Commits

SDK 0.148.0: one train for thirteen waiting PRs, on Contracts 0.406.0 (#223)

OP OpenVibers committed 2 days ago · a3c88824cf · 65 files, +1852 −124

* SDK: codes.tasks list, create and stop for the Dock's Code tab

* SDK 0.149.0: read wallet balance changes and read cursors

* Accept Billing transaction cursors for Vibes

* Add browser player handoff API for cross-site live streams

* Add personal chat memory SDK reader and controls

* Match SDK status to version 0.149.0

* Update SDK export manifest and package test

* Add public Exchange catalog search reader

* Update SDK release docs and browser bundle to 0.148.0

* Update SDK release metadata and generated entries

* Add owner agent sheet readers to SDK

* Match SDK status version to release

* Refresh SDK API surface for catalog search

* Refresh SDK browser bundle and release surface

* Give Windows PGlite revocation test a larger stack

* SDK 0.148.0: consolidate four feature releases

Consolidates #208 (composer client), #210 (Community feed
reader), #211 (one-time sign-in handoff) and #212 (agent run
stream client) onto 0.148.0 with one regenerated bundle, ESM
entries and API surface.

* Declare sheet browser export and update pack count

* app-signin test: look for a token's shape, not the letters eyJ (a random session id held them now and then)

* Add popular and try-next to the home feed SDK client

* Type image search filters and source metadata

* Type exact Community comment reply targets

* Synchronize SDK release metadata and browser bundle

* Merge remote-tracking branch 'origin/ai-memory-359-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/ask-358-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/fix/app-signin-flake' into release/sdk-0148

* Merge remote-tracking branch 'origin/l43-home-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/sdk/comment-reply-target-r3' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md

* Merge remote-tracking branch 'origin/gain-loss-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js

* Merge remote-tracking branch 'origin/ask360-sdk-r2' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	esm/index.mjs
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/pack.test.js

* Merge remote-tracking branch 'origin/cx/codes-tasks-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md
#	README.md
#	browser/openvibe-sdk.mjs
#	esm/index.mjs
#	package-lock.json
#	package.json
#	src/core/index.js

* Merge remote-tracking branch 'origin/cx/agent-sheet-306-sdk' into release/sdk-0148

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser.js
#	browser/openvibe-sdk.mjs
#	docs/api-surface.json
#	esm/index.mjs
#	index.js
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/pack.test.js
#	types/bundle.d.ts
#	types/index.d.ts

* SDK 0.148.0: the train for #207, #208, #210-#217, #219, #220 and #222, on Contracts 0.406.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bca64a7..498a20c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,28 @@
# Changelog
++## 0.148.0 — 2026-11-11
++
++- #208: add `openvibe-sdk/composer`, the shared composer's client: `attachments.upload(file, { onProgress, signal })` (multipart through `XMLHttpRequest`, real browser progress), `attachments.remove(id)`, `composer.prefs.get()/put(prefs, rev)` and `mentions.search(q, kinds)`, typed by `composer.attachment@1`, `composer.prefs@1` and `composer.mention@1`.
++- #210: add `feed.list({ kind, cursor })` for the cross-site Community Feed, keep the existing Community reader callable, and type preview video media.
++- #211: add the `openvibe-sdk/sso` one-time cross-site sign-in handoff: `POST /auth/handoff` mints a 60 s ticket and `sso.handoff()` arrival middleware redeems it before the page renders.
++- #212: add `agents` SDK methods to fetch run snapshots, follow resumable AG-UI event streams and post approvals.
++- #213: add `wallet.changes()` for the owner-scoped Vibes, OpenCoins and channel-points gain and loss window, and
++ `wallet.markRead()` for each currency's own read cursor.
++- #214: add browser-safe `player.current()`, `player.handoff(url)` and `player.on(listener)` for a live stream carried
++ between OpenVibe sites by Shared's floating player.
++- #215: add typed Chat readers for a person's compacted day, week and month memory, plus personal erase and opt-out.
++- #216: add `exchange.createCatalogSearchClient().search(q, filters)` for public model and provider offers (prices,
++ measured speed and Bench quality when available), and type the image search filters and source metadata.
++- #217: add the owner-only agent sheet reader and Actor pause, resume and memory controls through Network's relay.
++- #219: the app sign-in test checks a token's shape, not the letters "eyJ" (a macOS flake).
++- #220: `homeFeed.network.popular({ limit })` (the signed-out front door's five things people do now) and
++ `homeFeed.network.tryNext({ limit })` (the signed-in person's own suggestions).
++- #222: type the exact reply target and its display name on a Community comment; a thread's first page includes two
++ replies and the rest use the parent cursor.
++- #207: add `codes.tasks` for the Dock's Code tab: `list({ limit })`, `create({ text, mode, repo?, attachments? })` and
++ `stop(id)` (`codes.task-summary@1`).
++- openvibe-contracts pinned at v0.406.0.
++
## 0.147.0 — 2026-11-10
- #181: add `chat.activity` (the person's feed: mentions, replies, reactions, server invites, accepted friend requests and roles), `rooms.react()` and `reply_to_id` on room and DM sends.
ddiff --git a/README.md b/README.md
iindex a54096c..a9feb2b 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.148.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.147.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.148.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.148.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).
@@@ -203,9 +203,11 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/sso` | server | `createSsoClient({site, baseUrl, clientId, clientSecret, networkUrl, networkInternalUrl, secureCookies})`: a product site's sign-in with OpenVibe.Network: `router(express)` (`/login` with state + PKCE S256, `/callback`, `/fedcm`, `/logout`, `/me`, `/refresh`), `optionalAuth()`, `requireAuth()`, `verify(token)` (offline, shared JWKS), `extractToken(req)`; `sanitizeNext`, `COOKIES` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
++| `openvibe-sdk/composer` | both | `createComposerClient(client, {mediaBaseUrl?, networkBaseUrl?})`: `attachments.upload(file, {onProgress, signal})` (multipart to Media through `XMLHttpRequest`, real browser progress), `attachments.remove(id)`, `composer.prefs.get()/put(prefs, rev)` (a stale revision is 409 `composer.rev_conflict`), `mentions.search(q, kinds)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, shared `comments.resolve/get/upload/create/edit/delete/visibility`, `as(subject)` |
| `openvibe-sdk/history` | both | Private history reads: cursor pages, title search, day/week summaries, recent agent reads, settings and owner-scoped deletes. |
| `openvibe-sdk/home-feed` | both | `createHomeFeedClient(client)` gives `ov.home.get()/set(layout)`, `ov.network.today()`, `ov.feed.list({ for: 'me', cursor })`, `ov.feed.post(text)`, and `ov.feed.contribute({ id, text, url })`. Personal calls need a user Bearer token; contribution needs an app token granted `community.pulse.contribute`. Today is public and cached by Network for five minutes. |
++| `openvibe-sdk/feed` | both | `createFeedClient(client)` gives `ov.feed.list({ kind, cursor })` for the public cross-site Community Feed. Pass the returned `next_cursor` as `cursor` to read the next page. |
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `embedUrl(robotId, mode)` (panel, overlay or compact), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` |
| `openvibe-sdk/bench` | both | `createBenchClient(client, {actingSubject?})`: `kinds.list/get`, `tasks.create/get`, `rounds.create/get`, `entries.submit`, `judgements.next/cast`, `boards.get`, `ratings.get` (public, at least twenty games), `models.get`, `datasets.get/iterate`, `evals.create/get/run/results`, `mcpTools()`. Audience `openvibe.bench`; service id `bench`. |
@@@ -217,6 +219,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
++| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
ddiff --git a/STATUS.json b/STATUS.json
iindex 3a896d1..4d8a80c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.147.0",
++ "version": "0.148.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",
@@@ -50,5 +50,5 @@
"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-10"
++ "updated": "2026-11-11"
}
ddiff --git a/browser.js b/browser.js
iindex c07e0ac..5dd99b5 100644
---- a/browser.js
++++ b/browser.js
@@@ -16,11 +16,13 @@ module.exports = {
modules: require('./src/modules'),
realtime: require('./src/realtime'),
media: require('./src/media'),
++ composer: require('./src/composer'),
cam: require('./src/cam'),
mediaUpload: require('./src/media-upload'),
ui: require('./src/ui'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
++ feed: require('./src/feed'),
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
@@@ -33,9 +35,12 @@ module.exports = {
ai: require('./src/ai'),
credentials: require('./src/credentials'),
jobs: require('./src/jobs'),
++ agents: require('./src/agents'),
tools: require('./src/tools'),
projects: require('./src/projects'),
geo: require('./src/geo'),
history: require('./src/history'),
userCard: require('./src/user-card'),
++ player: require('./src/player'),
++ agentSheet: require('./src/agent-sheet'),
};
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 1973551..15c8895 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.147.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.148.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, locker, registry, trust, levels, modules, realtime, media, cam, mediaUpload, ui, community, homeFeed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, tools, projects, geo, history, userCard). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, community, homeFeed, feed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, agents, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -23,28 +23,33 @@ module.exports = {
modules: __load(24),
realtime: __load(25),
media: __load(26),
-- cam: __load(28),
-- mediaUpload: __load(29),
-- ui: __load(30),
-- community: __load(31),
-- homeFeed: __load(32),
-- bot: __load(33),
-- search: __load(34),
-- bench: __load(35),
-- meme: __load(36),
-- vision: __load(37),
-- themes: __load(38),
-- design: __load(39),
-- redact: __load(40),
-- chat: __load(41),
-- ai: __load(43),
-- credentials: __load(45),
-- jobs: __load(46),
-- tools: __load(48),
-- projects: __load(49),
-- geo: __load(50),
-- history: __load(51),
-- userCard: __load(52),
++ composer: __load(28),
++ cam: __load(29),
++ mediaUpload: __load(30),
++ ui: __load(31),
++ community: __load(32),
++ homeFeed: __load(33),
++ feed: __load(34),
++ bot: __load(35),
++ search: __load(36),
++ bench: __load(37),
++ meme: __load(38),
++ vision: __load(39),
++ themes: __load(40),
++ design: __load(41),
++ redact: __load(42),
++ chat: __load(43),
++ ai: __load(45),
++ credentials: __load(47),
++ jobs: __load(48),
++ agents: __load(50),
++ tools: __load(51),
++ projects: __load(52),
++ geo: __load(53),
++ history: __load(54),
++ userCard: __load(55),
++ player: __load(56),
++ agentSheet: __load(57),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -59,7 +64,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.147.0';
++const SDK_VERSION = '0.148.0';
module.exports = {
createClient,
@@@ -711,10 +716,11 @@ module.exports = { paginate, offsetPager };
function (module, exports) {
'use strict';
--/** Personal bearer wallet/store facade. Amounts and ownership are enforced by Billing and Inventory. */
--function createWalletClient(client, { billingUrl, inventoryUrl } = {}) {
++/** Personal bearer wallet/store facade. Amounts and ownership are enforced by their ledger services. */
++function createWalletClient(client, { billingUrl, inventoryUrl, networkUrl } = {}) {
if (!client || typeof client.json !== 'function') throw new TypeError('createWalletClient requires an SDK core client');
-- const call = (service, path, opts = {}) => client.json({ service, baseUrl: service === 'billing' ? billingUrl : inventoryUrl,
++ const urls = { billing: billingUrl, inventory: inventoryUrl, network: networkUrl };
++ const call = (service, path, opts = {}) => client.json({ service, baseUrl: urls[service],
audience: `openvibe.${service}`, path, credentials: 'omit', ...opts });
const key = (v) => {
if (typeof v !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(v)) throw new TypeError('An idempotency_key of 8–128 letters, digits, dot, colon, underscore or dash is required');
@@@ -722,12 +728,38 @@ function createWalletClient(client, { billingUrl, inventoryUrl } = {}) {
};
const whole = (v) => { if (!Number.isSafeInteger(v) || v < 1) throw new TypeError('Choose a positive whole amount'); return v; };
const itemId = (v) => { if (!/^itd_[0-9A-HJKMNP-TV-Z]{26}$/.test(v || '')) throw new TypeError('An item id is an itd_ id'); return encodeURIComponent(v); };
++ const cursor = (v, currency) => {
++ if (v == null) return v;
++ if (currency === 'vibes') {
++ if (typeof v !== 'string' || !/^txn_[0-9A-HJKMNP-TV-Z]{26}$/.test(v)) throw new TypeError('vibes cursor is a txn_ id');
++ } else if (!Number.isSafeInteger(Number(v)) || Number(v) <= 0 || !/^[0-9]+$/.test(String(v))) {
++ throw new TypeError('cursor is a positive move id');
++ }
++ return v;
++ };
++ const streamer = (v) => { if (!Number.isSafeInteger(v) || v <= 0) throw new TypeError('streamer is a positive user id'); return v; };
// The BODY key is durable in both services. Retries keep it; the SDK must not add an unrelated header key.
const write = (service, path, json, { signal } = {}) => call(service, path, { method: 'POST', json,
idempotencyKey: false, idempotent: true, signal });
return {
wallet: {
get: ({ signal } = {}) => call('billing', '/api/v1/me/wallet', { signal }),
++ changes: ({ since_vibes, since_opencoins, since_channel_points, streamer: channel, limit, signal } = {}) => {
++ if (limit != null && (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)) throw new TypeError('limit is 1 to 500');
++ return call('network', '/api/wallet/changes', { query: {
++ since_vibes: cursor(since_vibes, 'vibes'), since_opencoins: cursor(since_opencoins, 'opencoins'),
++ since_channel_points: cursor(since_channel_points, 'channel-points'),
++ streamer: channel == null ? undefined : streamer(channel), limit,
++ }, signal });
++ },
++ markRead: ({ currency, cursor: at, streamer: channel }, { signal } = {}) => {
++ if (!['vibes', 'opencoins', 'channel-points'].includes(currency)) throw new TypeError('currency is vibes, opencoins or channel-points');
++ if (at == null) throw new TypeError('cursor is required');
++ if (currency === 'channel-points' && channel == null) throw new TypeError('streamer is required for channel points');
++ return call('network', '/api/wallet/changes/read', { method: 'PUT', json: {
++ currency, cursor: cursor(at, currency), ...(currency === 'channel-points' ? { streamer: streamer(channel) } : {}),
++ }, signal });
++ },
topUp: ({ provider, amount_cents, idempotency_key }, options) => write('billing', '/api/v1/me/wallet/topups',
{ provider, amount_cents: whole(amount_cents), idempotency_key: key(idempotency_key) }, options),
},
@@@ -3306,7 +3338,150 @@ function createObjectsClient({
module.exports = { createObjectsClient };
},
--// ── 28: src/cam/index.js ────────────────────────────────────────────
++// ── 28: src/composer.js ─────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/composer: the shared composer's client, the same on every OpenVibe site.
++ *
++ * attachments.upload(file, { onProgress, signal, filename, contentType, xhr })
++ * POST /api/v1/attachments on Media (multipart `file`), through XMLHttpRequest so upload progress is real.
++ * -> composer.attachment@1
++ * attachments.remove(id)
++ * DELETE /api/v1/attachments/<id>; true when it went, false when it was already gone.
++ * composer.prefs.get() / composer.prefs.put(prefs, rev)
++ * GET/PUT /api/v1/me/composer on Network (composer.prefs@1). A PUT sends the revision it read; a stale one is
++ * 409 composer.rev_conflict and err.problem.current is the other tab's document.
++ * mentions.search(q, kinds)
++ * GET /api/v1/mentions on Network (composer.mention@1): people the caller follows first, then public people,
++ * repositories from OpenVibe Git, first-party sites. Signed-in only; at most 8.
++ *
++ * Browser-safe. The composer page calls Network directly for prefs and mentions (every site's connect-src allows it)
++ * and Media for the bytes (the site's CSP must allow OPENVIBE_MEDIA_ORIGIN).
++ */
++const { OpenVibeError, isOpenVibeError } = __load(3);
++
++const MEDIA_ORIGIN = 'https://openvibe.media';
++const NETWORK_ORIGIN = 'https://openvibe.network';
++const ATTACHMENT_MAX = 300 * 1024 * 1024;
++const enc = encodeURIComponent;
++
++function toBlob(file, contentType) {
++ if (typeof Blob !== 'undefined' && file instanceof Blob) return file;
++ if (typeof file === 'string' || file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {
++ return new Blob([file], contentType ? { type: contentType } : undefined);
++ }
++ throw new TypeError('attachments.upload: pass a File/Blob, ArrayBuffer, typed array (Buffer) or string');
++}
++
++function abortError() {
++ return new OpenVibeError({ code: 'sdk.aborted', message: 'the upload was aborted' });
++}
++
++function createComposerClient(client, { mediaBaseUrl, networkBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createComposerClient: an SDK client is required');
++ const net = (opts) => client.json({ service: 'network', audience: 'openvibe.network', ...(networkBaseUrl ? { baseUrl: networkBaseUrl } : {}), ...opts });
++
++ /** The Media token for an XHR upload (the XHR cannot borrow the fetch client's request loop). */
++ async function mediaToken() {
++ const o = client.options || {};
++ if (o.token != null) return o.token;
++ if (typeof o.getToken === 'function') return (await o.getToken({ service: 'media', audience: 'openvibe.media' })) || null;
++ return null;
++ }
++
++ const attachments = {
++ /**
++ * One file -> composer.attachment@1. Progress is the browser's upload progress (XHR), not a guess.
++ * `xhr` injects an XMLHttpRequest implementation (tests, a non-browser shell); otherwise the global one.
++ */
++ upload(file, { onProgress, signal, filename, contentType, xhr } = {}) {
++ return new Promise((resolve, reject) => {
++ const XHR = xhr || (typeof XMLHttpRequest !== 'undefined' ? XMLHttpRequest : null);
++ if (!XHR) return reject(new OpenVibeError({ code: 'sdk.xhr_unavailable', message: 'attachments.upload needs XMLHttpRequest (pass { xhr })' }));
++ let blob;
++ try { blob = toBlob(file, contentType); } catch (err) { return reject(err); }
++ if (blob.size > ATTACHMENT_MAX) {
++ return reject(new OpenVibeError({ code: 'media.attachment.too_large', status: 413, message: `a file may be at most ${ATTACHMENT_MAX / (1024 * 1024)} MB` }));
++ }
++ const url = client.url({ service: 'media', ...(mediaBaseUrl ? { baseUrl: mediaBaseUrl } : {}), path: '/api/v1/attachments' });
++ Promise.resolve(url).then(async (target) => {
++ if (signal && signal.aborted) return reject(abortError());
++ const fd = new FormData();
++ fd.append('file', blob, filename || (file && file.name) || 'file');
++ const req = new XHR();
++ let settled = false;
++ const finish = (fn, v) => { if (settled) return; settled = true; cleanup(); fn(v); };
++ const offAbort = signal && typeof signal.addEventListener === 'function' ? () => signal.removeEventListener('abort', onAbort) : () => {};
++ function onAbort() { try { req.abort(); } catch { /* */ } finish(reject, abortError()); }
++ const cleanup = () => { offAbort(); };
++ req.open('POST', target);
++ req.setRequestHeader('Accept', 'application/json');
++ const token = await mediaToken();
++ if (token) req.setRequestHeader('Authorization', `Bearer ${token}`);
++ if (req.upload && typeof onProgress === 'function') {
++ req.upload.onprogress = (e) => onProgress({ loaded: e.loaded || 0, total: e.total || blob.size, lengthComputable: e.lengthComputable !== false });
++ }
++ req.onload = () => {
++ let body = null;
++ try { body = req.responseText ? JSON.parse(req.responseText) : null; } catch { body = req.responseText || null; }
++ if (req.status >= 200 && req.status < 300) return finish(resolve, body);
++ const err = OpenVibeError.fromResponse({ status: req.status, body, method: 'POST', url: target });
++ if (body && body.current) err.current = body.current;
++ finish(reject, err);
++ };
++ req.onerror = () => finish(reject, new OpenVibeError({ code: 'sdk.network_error', message: 'the upload could not reach Media', method: 'POST', url: target }));
++ req.ontimeout = () => finish(reject, new OpenVibeError({ code: 'sdk.timeout', message: 'the upload timed out', method: 'POST', url: target }));
++ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
++ try { req.send(fd); } catch (err) { finish(reject, err); }
++ }).catch(reject);
++ });
++ },
++
++ /** DELETE /api/v1/attachments/<id>: true when it went, false when it was already gone. */
++ async remove(id) {
++ if (!id) throw new TypeError('attachments.remove: pass the attachment id');
++ try {
++ await client.json({ service: 'media', ...(mediaBaseUrl ? { baseUrl: mediaBaseUrl } : {}), audience: 'openvibe.media', method: 'DELETE', path: `/api/v1/attachments/${enc(id)}` });
++ return true;
++ } catch (err) {
++ if (isOpenVibeError(err) && err.status === 404) return false;
++ throw err;
++ }
++ },
++ };
++
++ const composer = {
++ prefs: {
++ /** composer.prefs@1, with its rev. */
++ get: () => net({ path: '/api/v1/me/composer' }),
++ /**
++ * composer.prefs@1 in (rev is the one you read; 0 for a new person), the new document out.
++ * A stale rev is 409 composer.rev_conflict with err.problem.current (and err.current).
++ */
++ put(prefs, rev) {
++ if (!prefs || typeof prefs !== 'object') throw new TypeError('composer.prefs.put: pass the prefs object');
++ if (!Number.isInteger(rev) || rev < 0) throw new TypeError('composer.prefs.put: pass the revision you read (0 for new)');
++ return net({ method: 'PUT', path: '/api/v1/me/composer', json: { ...prefs, rev } });
++ },
++ },
++ };
++
++ const mentions = {
++ /** @ menu entries (composer.mention@1[]). kinds: person | repo | site | file; omitted = all. */
++ async search(q, kinds) {
++ const list = Array.isArray(kinds) ? kinds : (kinds ? String(kinds).split(',') : []);
++ const data = await net({ path: '/api/v1/mentions', query: { q: q || undefined, kinds: list.length ? list.join(',') : undefined } });
++ return (data && Array.isArray(data.mentions)) ? data.mentions : [];
++ },
++ };
++
++ return { attachments, composer, mentions, MEDIA_ORIGIN, NETWORK_ORIGIN };
++}
++
++module.exports = { createComposerClient, MEDIA_ORIGIN, NETWORK_ORIGIN, ATTACHMENT_MAX };
++},
++// ── 29: src/cam/index.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3416,7 +3591,7 @@ function defineOvCamera(options = {}) {
module.exports = { defineOvCamera, OV_CAMERA_SRC, CAM_KIT_SRC, CAM_KIT_SRI, loadCamKit, open, settings };
},
--// ── 29: src/media-upload.js ─────────────────────────────────────────
++// ── 30: src/media-upload.js ─────────────────────────────────────────
function (module, exports) {
'use strict';
const { createObjectsClient } = __load(26);
@@@ -3436,7 +3611,7 @@ function createUploadClient(config) {
module.exports = { createUploadClient };
},
--// ── 30: src/ui.js ───────────────────────────────────────────────────
++// ── 31: src/ui.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -3449,7 +3624,7 @@ function progress(el, options) {
module.exports = { progress };
},
--// ── 31: src/community.js ────────────────────────────────────────────
++// ── 32: src/community.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3566,6 +3741,8 @@ function createCommunityClient(client, defaults = {}) {
query: { kind: query.kind, site: query.site, q: query.q, cursor: query.cursor, limit: query.limit,
following: query.following === true || query.following === 1 ? '1' : undefined },
}, o);
++ // Named form used by new integrations; the callable form remains for existing SDK consumers.
++ feed.list = feed;
const posts = {
/**
* A status post in the Feed at once: { text, pictures?: [{ media_id, alt }] } (at most four, alt required,
@@@ -3610,15 +3787,16 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
},
--// ── 32: src/home-feed.js ────────────────────────────────────────────
++// ── 33: src/home-feed.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
--/** The menu's portable Home layout, network day counts and personal wall. Pass a user-token core client;
-- * contribute() needs an app token with the person's community.pulse.contribute grant. */
++/** The menu's portable Home layout, network day counts, what people do now and personal wall. Pass a user-token core
++ * client; contribute() needs an app token with the person's community.pulse.contribute grant. */
function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {}) {
if (!client || typeof client.json !== 'function') throw new TypeError('createHomeFeedClient: pass an openvibe-sdk/core client');
const network = (opts) => client.json({ service: 'network', baseUrl: networkBaseUrl, audience: 'openvibe.network', ...opts });
const community = (opts) => client.json({ service: 'community', baseUrl: communityBaseUrl, audience: 'openvibe.community', ...opts });
++ const limitQuery = (limit) => (limit == null ? undefined : { limit });
return {
home: {
get: ({ signal } = {}) => network({ path: '/api/v1/me/home-screen', signal }),
@@@ -3626,6 +3804,10 @@ function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {})
},
network: {
today: ({ signal } = {}) => network({ path: '/api/v1/frame/today', auth: false, signal }),
++ // The signed-out front door's list: Report's public aggregate, no credentials sent.
++ popular: ({ limit, signal } = {}) => network({ path: '/api/v1/home/popular', query: limitQuery(limit), auth: false, signal }),
++ // The signed-in person's own suggestions; Network computes them in the caller's request.
++ tryNext: ({ limit, signal } = {}) => network({ path: '/api/v1/me/try-next', query: limitQuery(limit), signal }),
},
feed: {
list: ({ for: audience = 'me', cursor, signal } = {}) => community({ path: '/api/v1/pulse', query: { for: audience, cursor }, signal }),
@@@ -3637,7 +3819,25 @@ function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {})
module.exports = { createHomeFeedClient };
},
--// ── 33: src/bot.js ──────────────────────────────────────────────────
++// ── 34: src/feed.js ─────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++
++/** Browser-safe reader for the public, cross-site Feed cards on OpenVibe.Community. */
++function createFeedClient(client, { communityBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createFeedClient: pass an openvibe-sdk/core client');
++ return {
++ /** Read one page of cards; pass next_cursor back as cursor to continue. */
++ list: ({ kind, cursor } = {}, { signal } = {}) => client.json({
++ service: 'community', baseUrl: communityBaseUrl, audience: 'openvibe.community',
++ path: '/api/v1/feed', query: { kind, cursor }, signal,
++ }),
++ };
++}
++
++module.exports = { createFeedClient };
++},
++// ── 35: src/bot.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -3878,7 +4078,7 @@ function createBotClient(client, defaults = {}) {
module.exports = { createBotClient, actingHeaders };
},
--// ── 34: src/search.js ───────────────────────────────────────────────
++// ── 36: src/search.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4024,7 +4224,7 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf, fabricOf, codeOf };
},
--// ── 35: src/bench.js ────────────────────────────────────────────────
++// ── 37: src/bench.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/** openvibe-sdk/bench: OpenVibe.Bench's JSON API and streamed open dataset. Browser-safe. */
@@@ -4132,7 +4332,7 @@ function createBenchClient(client, defaults = {}) {
module.exports = { createBenchClient };
},
--// ── 36: src/meme.js ─────────────────────────────────────────────────
++// ── 38: src/meme.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4215,7 +4415,7 @@ function createMemeClient(client, defaults = {}) {
module.exports = { createMemeClient };
},
--// ── 37: src/vision.js ───────────────────────────────────────────────
++// ── 39: src/vision.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4373,7 +4573,7 @@ function pick(o, keys) {
module.exports = { createVisionClient, DEFAULT_AUDIENCE };
},
--// ── 38: src/themes.js ───────────────────────────────────────────────
++// ── 40: src/themes.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4523,7 +4723,7 @@ function followTheme(fn, opts = {}) {
module.exports = { createThemesClient, followTheme, readTheme, applyTheme, seasonOn, onColor, DISPLAY_OPTIONS, TOKENS };
},
--// ── 39: src/design.js ───────────────────────────────────────────────
++// ── 41: src/design.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -4590,7 +4790,7 @@ function createDesignClient({ accessToken = null, baseUrl = DEFAULT_DESIGN_URL,
module.exports = { createDesignClient, DEFAULT_DESIGN_URL };
},
--// ── 40: src/redact.js ───────────────────────────────────────────────
++// ── 42: src/redact.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4837,7 +5037,7 @@ function onlySecret(text) {
module.exports = { redact, onlySecret };
},
--// ── 41: src/chat.js ─────────────────────────────────────────────────
++// ── 43: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -4870,7 +5070,7 @@ function (module, exports) {
const { isOpenVibeError } = __load(3);
const enc = encodeURIComponent;
--const history = __load(42);
++const history = __load(44);
/** The kinds chat.embed@1 names; anything else is not a preview this SDK returns. */
const EMBED_KINDS = new Set(['link', 'video', 'image', 'live', 'picture', 'design', 'post', 'profile', 'game']);
@@@ -4913,6 +5113,14 @@ function createChatClient(client, defaults = {}) {
history,
/** The typed address previews on a message (chat.embed@1): [] when it has none. */
embeds: (message) => readEmbeds(message),
++ /** Compacted day, week or month memory for a person (chat.person-memory-result@1). */
++ people: {
++ memory: (username, { channel, level = 'day' } = {}, o = {}) => call({ path: `/api/v1/people/${enc(String(username))}/memory`, query: { channel, level } }, o),
++ /** Erase the signed-in person's memory and opt out. */
++ forget: (username, o = {}) => call({ method: 'DELETE', path: `/api/v1/people/${enc(String(username))}/memory`, ...once }, o),
++ /** Set the signed-in person's opt-out preference. */
++ preferences: (username, optedOut, o = {}) => call({ method: 'PUT', path: `/api/v1/people/${enc(String(username))}/memory/preferences`, json: { opted_out: !!optedOut }, ...once }, o),
++ },
/** Vibe's model comparison API, authenticated as the person. */
ask: (input, o = {}) => call({ method: 'POST', path: '/api/vibe/ask', json: input, ...once }, o),
pick: (messageId, model, o = {}) => call({ method: 'POST', path: '/api/vibe/pick', json: { message_id: messageId, model }, ...once }, o),
@@@ -5151,7 +5359,7 @@ function createFeedPublisher(client, { feed, key, baseUrl } = {}) {
module.exports = { createChatClient, createInboxVisitor, createFeedPublisher, history, readEmbeds };
},
--// ── 42: src/chat-history.js ─────────────────────────────────────────
++// ── 44: src/chat-history.js ─────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -5230,7 +5438,7 @@ function attach({ feed, status = null, fetchPage, render, hasMore = true, thresh
module.exports = { pages, attach };
},
--// ── 43: src/ai.js ───────────────────────────────────────────────────
++// ── 45: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5408,7 +5616,7 @@ function createAiClient(client, defaults = {}) {
remove(id) { requireFiles('remove'); return fileObjects.delete(id); },
};
-- const metrics = __load(44).createMetricsClient(call);
++ const metrics = __load(46).createMetricsClient(call);
const api = { tasks, creations, choices, run, admin, runs, batches, files, metrics, TERMINAL, CLASSES };
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
@@@ -5421,7 +5629,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
},
--// ── 44: src/ai-metrics.js ───────────────────────────────────────────
++// ── 46: src/ai-metrics.js ───────────────────────────────────────────
function (module, exports) {
'use strict';
// Public measured reads: no bearer token, prompt, person or site identifiers.
@@@ -5450,7 +5658,7 @@ function createMetricsClient(call) {
}
module.exports = { createMetricsClient };
},
--// ── 45: src/credentials.js ──────────────────────────────────────────
++// ── 47: src/credentials.js ──────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -5468,7 +5676,7 @@ function createCredentialsClient(client, defaults = {}) {
module.exports = { createCredentialsClient };
},
--// ── 46: src/jobs.js ─────────────────────────────────────────────────
++// ── 48: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5506,7 +5714,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(47);
++const { appendFiles } = __load(49);
const { parseSSE } = __load(25);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -5670,7 +5878,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 47: src/core/form.js ────────────────────────────────────────────
++// ── 49: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5704,7 +5912,62 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 48: src/tools.js ────────────────────────────────────────────────
++// ── 50: src/agents.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/** Agent run snapshots, AG-UI event streams and approval responses. */
++const { OpenVibeError, isOpenVibeError } = __load(3);
++const { parseSSE } = __load(25);
++const enc = encodeURIComponent;
++const pause = (ms, signal) => new Promise((resolve) => {
++ const t = setTimeout(resolve, ms);
++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
++});
++function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network' } = {}) {
++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ async function run(id, { signal } = {}) {
++ if (!id) throw new TypeError('agents.run: id is required');
++ try { return (await call({ path: `/api/v1/runs/${enc(id)}`, signal })).data; }
++ catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
++ }
++ async function post(id, approval, { signal } = {}) {
++ if (!id) throw new TypeError('agents.post: id is required');
++ if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
++ return (await call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ }
++ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
++ if (!id) throw new TypeError('agents.events: id is required');
++ let last = String(lastEventId || '0'), failures = 0, serverRetryMs = 0;
++ while (!(signal && signal.aborted)) {
++ let delivered = false;
++ try {
++ const res = await call({ path: `/api/v1/runs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
++ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': last } });
++ if (res.status === 204) return;
++ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { serverRetryMs = ms; } })) {
++ if (msg.id !== undefined && msg.id !== '') { if (/^\d+$/.test(msg.id) && Number(msg.id) <= Number(last)) continue; last = msg.id; }
++ let data; try { data = JSON.parse(msg.data); } catch { data = null; }
++ delivered = true; failures = 0;
++ yield { id: msg.id ?? null, event: msg.event || (data && data.type) || null, data };
++ if (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type)) return;
++ }
++ } catch (err) {
++ if (signal && signal.aborted) return;
++ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;
++ if (!isOpenVibeError(err) && !['TypeError', 'AbortError'].includes(err && err.name)) throw err;
++ }
++ failures = delivered ? 0 : failures + 1;
++ if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: event stream ${id} failed ${failures} times in a row` });
++ const backoffMs = Math.min(reconnectDelayMs * (2 ** Math.max(0, failures - 1)), 30000);
++ const waitMs = Math.min(Math.max(backoffMs, serverRetryMs), 30000);
++ await pause(waitMs, signal);
++ }
++ }
++ return { run, events, post };
++}
++module.exports = { createAgentsClient };
++},
++// ── 51: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -5738,8 +6001,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(47);
--const { createJobsClient } = __load(46);
++const { isUpload, appendFiles } = __load(49);
++const { createJobsClient } = __load(48);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -6001,7 +6264,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 49: src/projects.js ─────────────────────────────────────────────
++// ── 52: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -6117,7 +6380,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
},
--// ── 50: src/geo.js ──────────────────────────────────────────────────
++// ── 53: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -6181,7 +6444,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
module.exports = { createGeoClient };
},
--// ── 51: src/history.js ──────────────────────────────────────────────
++// ── 54: src/history.js ──────────────────────────────────────────────
function (module, exports) {
'use strict';
@@@ -6209,7 +6472,7 @@ function createHistoryClient(client, { baseUrl } = {}) {
module.exports = { createHistoryClient };
},
--// ── 52: src/user-card.js ────────────────────────────────────────────
++// ── 55: src/user-card.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/** One public Network aggregate. Hidden/unavailable fields stay absent; no credentials are needed. */
@@@ -6225,6 +6488,53 @@ function createUserCardClient(client, { baseUrl } = {}) {
}
module.exports = { createUserCardClient };
},
++// ── 56: src/player.js ───────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/** Browser facade for Shared's portable live player. */
++function host() { return typeof window === 'undefined' ? null : window.OVOverlay || null; }
++function current() { return host()?.current() || null; }
++function handoff(url) { return host()?.handoffUrl(url) || url; }
++function on(listener) {
++ if (typeof listener !== 'function') throw new TypeError('listener must be a function');
++ if (typeof window === 'undefined') return () => {};
++ const receive = (event) => listener(event.detail);
++ window.addEventListener('openvibe:player', receive);
++ return () => window.removeEventListener('openvibe:player', receive);
++}
++module.exports = { current, handoff, on };
++},
++// ── 57: src/agent-sheet.js ──────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/** Owner-only agent sheets through Network's fixed cross-site relay. The caller supplies their own Bearer. */
++const ACTOR = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MEMORY = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CODES = /^[a-z][a-z0-9-]{0,63}$/;
++
++function createAgentSheetClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createAgentSheetClient: pass an SDK core client');
++ const id = (value, pattern, kind) => {
++ if (!pattern.test(String(value || ''))) throw new TypeError(`invalid ${kind} id`);
++ return String(value);
++ };
++ const path = (agent) => `/api/frame/agents/${id(agent, ACTOR, 'Actor agent')}`;
++ const call = (opts) => client.json({ service: 'network', audience: 'openvibe.network', baseUrl, ...opts });
++ return {
++ sheet: (agent, { signal } = {}) => call({ path: `${path(agent)}/sheet`, signal }),
++ codesSheet: (agent, { signal } = {}) => call({ path: `/api/frame/codes-agents/${id(agent, CODES, 'coding agent')}/sheet`, signal }),
++ pause: (agent, { signal } = {}) => call({ method: 'PATCH', path: path(agent), json: { status: 'paused' }, signal, idempotent: true }),
++ resume: (agent, { signal } = {}) => call({ method: 'PATCH', path: path(agent), json: { status: 'active' }, signal, idempotent: true }),
++ memory: {
++ add: (agent, text, { signal } = {}) => call({ method: 'POST', path: `${path(agent)}/memory`, json: { text }, signal, idempotent: false }),
++ update: (agent, note, text, { signal } = {}) => call({ method: 'PATCH', path: `${path(agent)}/memory/${id(note, MEMORY, 'memory')}`, json: { text }, signal, idempotent: true }),
++ remove: (agent, note, { signal } = {}) => call({ method: 'DELETE', path: `${path(agent)}/memory/${id(note, MEMORY, 'memory')}`, signal, idempotent: true }),
++ },
++ };
++}
++
++module.exports = { createAgentSheetClient };
++},
];
const __cache = new Array(__defs.length);
function __load(i) {
@@@ -6241,9 +6551,10 @@ export const {
parseTraceparent, startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey,
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
auth, locker, registry, trust, levels, modules,
-- realtime, media, cam, mediaUpload, ui, community,
-- homeFeed, bot, search, bench, meme, vision,
-- themes, design, redact, chat, ai, credentials,
-- jobs, tools, projects, geo, history, userCard,
++ realtime, media, composer, cam, mediaUpload, ui,
++ community, homeFeed, feed, bot, search, bench,
++ meme, vision, themes, design, redact, chat,
++ ai, credentials, jobs, agents, tools, projects,
++ geo, history, userCard, player, agentSheet,
} = sdk;
export default sdk;
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 2b2e2c1..289abdf 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -9,6 +9,16 @@
"createActorClient"
]
},
++ "agentSheet": {
++ "object": [
++ "createAgentSheetClient"
++ ]
++ },
++ "agents": {
++ "object": [
++ "createAgentsClient"
++ ]
++ },
"ai": {
"object": [
"CLASSES",
@@@ -101,6 +111,11 @@
"verifyTelegram"
]
},
++ "codes": {
++ "object": [
++ "createCodesClient"
++ ]
++ },
"commerce": {
"object": [
"CommerceError",
@@@ -116,6 +131,14 @@
]
},
"compareVersions": "function",
++ "composer": {
++ "object": [
++ "ATTACHMENT_MAX",
++ "MEDIA_ORIGIN",
++ "NETWORK_ORIGIN",
++ "createComposerClient"
++ ]
++ },
"contextFromHeaders": "function",
"coupons": {
"object": [
@@@ -165,6 +188,11 @@
"verifyDeliveryV2"
]
},
++ "feed": {
++ "object": [
++ "createFeedClient"
++ ]
++ },
"food": {
"object": [
"createFoodClient"
@@@ -323,6 +351,13 @@
"createPlayClient"
]
},
++ "player": {
++ "object": [
++ "current",
++ "handoff",
++ "on"
++ ]
++ },
"projects": {
"object": [
"createProjectsClient"
@@@ -516,6 +551,12 @@
"openvibe-sdk/actor": {
"createActorClient": "function"
},
++ "openvibe-sdk/agent-sheet": {
++ "createAgentSheetClient": "function"
++ },
++ "openvibe-sdk/agents": {
++ "createAgentsClient": "function"
++ },
"openvibe-sdk/ai": {
"CLASSES": "array",
"OPS": "array",
@@@ -636,6 +677,9 @@
"verifySlack": "function",
"verifyTelegram": "function"
},
++ "openvibe-sdk/codes": {
++ "createCodesClient": "function"
++ },
"openvibe-sdk/commerce": {
"CommerceError": "class",
"createCommerceClient": "function",
@@@ -646,6 +690,12 @@
"actingHeaders": "function",
"createCommunityClient": "function"
},
++ "openvibe-sdk/composer": {
++ "ATTACHMENT_MAX": "number",
++ "MEDIA_ORIGIN": "string",
++ "NETWORK_ORIGIN": "string",
++ "createComposerClient": "function"
++ },
"openvibe-sdk/compute": {
"STRATEGIES": "array",
"TIERS": "array",
@@@ -791,12 +841,16 @@
"NETWORK_BASE_URL": "string",
"RETRY_STATUS": "object",
"SIGNATURE_TOLERANCE_SEC": "number",
++ "createCatalogSearchClient": "function",
"createExchangeClient": "function",
"verifyWebhook": "function"
},
"openvibe-sdk/explicit": {
"isExplicit": "function"
},
++ "openvibe-sdk/feed": {
++ "createFeedClient": "function"
++ },
"openvibe-sdk/food": {
"createFoodClient": "function"
},
@@@ -1031,6 +1085,11 @@
"openvibe-sdk/play": {
"createPlayClient": "function"
},
++ "openvibe-sdk/player": {
++ "current": "function",
++ "handoff": "function",
++ "on": "function"
++ },
"openvibe-sdk/projects": {
"createProjectsClient": "function"
},
@@@ -1176,6 +1235,7 @@
"refresh"
]
},
++ "HANDOFF_PARAM": "string",
"claimsToUser": "function",
"createSsoClient": "function",
"decodeJwtPayload": "function",
@@@ -1183,6 +1243,7 @@
"fedcmNonceMatches": "function",
"parseCookies": "function",
"sanitizeNext": "function",
++ "stripHandoffParam": "function",
"withParam": "function"
},
"openvibe-sdk/storage": {
ddiff --git a/esm/agent-sheet.mjs b/esm/agent-sheet.mjs
nnew file mode 100644
iindex 0000000..f9509a4
---- /dev/null
++++ b/esm/agent-sheet.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/agent-sheet.js. Do not edit.
++import cjs from '../src/agent-sheet.js';
++
++export const {
++ createAgentSheetClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/agents.mjs b/esm/agents.mjs
nnew file mode 100644
iindex 0000000..5e87c42
---- /dev/null
++++ b/esm/agents.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/agents.js. Do not edit.
++import cjs from '../src/agents.js';
++
++export const {
++ createAgentsClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/codes.mjs b/esm/codes.mjs
nnew file mode 100644
iindex 0000000..60e7a1f
---- /dev/null
++++ b/esm/codes.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/codes.js. Do not edit.
++import cjs from '../src/codes.js';
++
++export const {
++ createCodesClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/composer.mjs b/esm/composer.mjs
nnew file mode 100644
iindex 0000000..e6fe0dd
---- /dev/null
++++ b/esm/composer.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/composer.js. Do not edit.
++import cjs from '../src/composer.js';
++
++export const {
++ createComposerClient, MEDIA_ORIGIN, NETWORK_ORIGIN, ATTACHMENT_MAX,
++} = cjs;
++export default cjs;
ddiff --git a/esm/exchange.mjs b/esm/exchange.mjs
iindex 796db29..7d0d8f2 100644
---- a/esm/exchange.mjs
++++ b/esm/exchange.mjs
@@@ -2,7 +2,7 @@
import cjs from '../src/exchange.js';
export const {
-- createExchangeClient, ExchangeError, verifyWebhook, SIGNATURE_TOLERANCE_SEC, EXCHANGE_BASE_URL, NETWORK_BASE_URL,
-- DEFAULT_PATHS, RETRY_STATUS,
++ createCatalogSearchClient, createExchangeClient, ExchangeError, verifyWebhook, SIGNATURE_TOLERANCE_SEC, EXCHANGE_BASE_URL,
++ NETWORK_BASE_URL, DEFAULT_PATHS, RETRY_STATUS,
} = cjs;
export default cjs;
ddiff --git a/esm/feed.mjs b/esm/feed.mjs
nnew file mode 100644
iindex 0000000..607347a
---- /dev/null
++++ b/esm/feed.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/feed.js. Do not edit.
++import cjs from '../src/feed.js';
++
++export const {
++ createFeedClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 1da575a..214aa9f 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,14 +7,15 @@ export const {
isActingSubjectId, satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
report, share, locker, auth, registry, identity,
trust, levels, modules, events, realtime, media,
-- cam, mediaUpload, ui, community, homeFeed, bot,
-- search, bench, meme, vision, themes, design,
-- redact, chat, chatApps, ai, credentials, jobs,
-- tools, projects, resources, vip, quest, live,
-- inventory, tips, blog, reviews, wiki, coupons,
-- deals, shoes, trade, space, host, news,
-- food, actor, run, video, pics, work,
-- zone, git, play, download, openre, geo,
-- history, usage, commerce, moneyReasons, telemetry, userCard,
++ composer, cam, mediaUpload, ui, community, homeFeed,
++ feed, bot, search, bench, meme, vision,
++ themes, design, redact, chat, chatApps, ai,
++ credentials, jobs, agents, tools, projects, resources,
++ vip, quest, live, inventory, tips, blog,
++ reviews, wiki, coupons, deals, shoes, trade,
++ space, host, news, food, actor, codes,
++ run, video, pics, work, zone, git,
++ play, download, openre, geo, history, usage,
++ commerce, moneyReasons, telemetry, userCard, player, agentSheet,
} = cjs;
export default cjs;
ddiff --git a/esm/player.mjs b/esm/player.mjs
nnew file mode 100644
iindex 0000000..06ea275
---- /dev/null
++++ b/esm/player.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/player.js. Do not edit.
++import cjs from '../src/player.js';
++
++export const {
++ current, handoff, on,
++} = cjs;
++export default cjs;
ddiff --git a/esm/sso.mjs b/esm/sso.mjs
iindex 31f7410..a95e20e 100644
---- a/esm/sso.mjs
++++ b/esm/sso.mjs
@@@ -2,7 +2,7 @@
import cjs from '../src/sso/index.js';
export const {
-- createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser,
-- parseCookies, failurePage, COOKIES,
++ createSsoClient, sanitizeNext, withParam, stripHandoffParam, decodeJwtPayload, fedcmNonceMatches,
++ claimsToUser, parseCookies, failurePage, HANDOFF_PARAM, COOKIES,
} = cjs;
export default cjs;
ddiff --git a/index.js b/index.js
iindex 73654fe..5f3e950 100644
---- a/index.js
++++ b/index.js
@@@ -21,11 +21,13 @@ module.exports = {
events: require('./src/events'),
realtime: require('./src/realtime'),
media: require('./src/media'),
++ composer: require('./src/composer'),
cam: require('./src/cam'),
mediaUpload: require('./src/media-upload'),
ui: require('./src/ui'),
community: require('./src/community'),
homeFeed: require('./src/home-feed'),
++ feed: require('./src/feed'),
bot: require('./src/bot'),
search: require('./src/search'),
bench: require('./src/bench'),
@@@ -39,6 +41,7 @@ module.exports = {
ai: require('./src/ai'),
credentials: require('./src/credentials'),
jobs: require('./src/jobs'),
++ agents: require('./src/agents'),
tools: require('./src/tools'),
projects: require('./src/projects'),
resources: require('./src/resources'),
@@@ -59,6 +62,7 @@ module.exports = {
news: require('./src/news'),
food: require('./src/food'),
actor: require('./src/actor'),
++ codes: require('./src/codes'),
run: require('./src/run'),
video: require('./src/video'),
pics: require('./src/pics'),
@@@ -75,4 +79,6 @@ module.exports = {
moneyReasons: require('./src/money-reasons'),
telemetry: require('./src/telemetry'),
userCard: require('./src/user-card'),
++ player: require('./src/player'),
++ agentSheet: require('./src/agent-sheet'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex d5bad18..a07063c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.147.0",
++ "version": "0.148.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.147.0",
++ "version": "0.148.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.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.399.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
++ "version": "0.406.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
++ "integrity": "sha512-V/Tjss2UWvzI551bw3ACb/RqWg0lChxGITxfCVZ8qou2Docg0pBMvIjkeQoib56yq3r6UZGB6DGl+pA+qfjOiA==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex e3e5979..edcf39b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.147.0",
++ "version": "0.148.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",
@@@ -84,6 +84,11 @@
"import": "./esm/media.mjs",
"default": "./src/media.js"
},
++ "./composer": {
++ "types": "./types/composer.d.ts",
++ "import": "./esm/composer.mjs",
++ "default": "./src/composer.js"
++ },
"./media-upload": {
"types": "./types/media-upload.d.ts",
"import": "./esm/media-upload.mjs",
@@@ -288,6 +293,11 @@
"import": "./esm/jobs.mjs",
"default": "./src/jobs.js"
},
++ "./agents": {
++ "types": "./types/agents.d.ts",
++ "import": "./esm/agents.mjs",
++ "default": "./src/agents.js"
++ },
"./tools": {
"types": "./types/tools.d.ts",
"import": "./esm/tools.mjs",
@@@ -315,6 +325,12 @@
"import": "./esm/home-feed.mjs",
"default": "./src/home-feed.js"
},
++ "./feed": {
++ "types": "./types/feed.d.ts",
++ "browser": "./src/feed.js",
++ "import": "./esm/feed.mjs",
++ "default": "./src/feed.js"
++ },
"./geo": {
"types": "./types/geo.d.ts",
"import": "./esm/geo.mjs",
@@@ -594,6 +610,24 @@
"types": "./types/user-card.d.ts",
"import": "./esm/user-card.mjs",
"default": "./src/user-card.js"
++ },
++ "./player": {
++ "types": "./types/player.d.ts",
++ "browser": "./src/player.js",
++ "import": "./esm/player.mjs",
++ "default": "./src/player.js"
++ },
++ "./codes": {
++ "types": "./types/codes.d.ts",
++ "browser": null,
++ "import": "./esm/codes.mjs",
++ "default": "./src/codes.js"
++ },
++ "./agent-sheet": {
++ "types": "./types/agent-sheet.d.ts",
++ "browser": "./src/agent-sheet.js",
++ "import": "./esm/agent-sheet.mjs",
++ "default": "./src/agent-sheet.js"
}
},
"bin": {
@@@ -639,7 +673,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.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.406.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 9079b7a..d8ca272 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -13,6 +13,7 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ENTRIES = {
'user-card.mjs': '../src/user-card.js',
++ 'agent-sheet.mjs': '../src/agent-sheet.js',
'wallet.mjs': '../src/wallet.js',
'compute.mjs': '../src/compute/index.js',
'share.mjs': '../src/share.js',
@@@ -36,6 +37,7 @@ const ENTRIES = {
'cam.mjs': '../src/cam/index.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
++ 'composer.mjs': '../src/composer.js',
'media-upload.mjs': '../src/media-upload.js',
'ui.mjs': '../src/ui.js',
'community.mjs': '../src/community.js',
@@@ -71,6 +73,7 @@ const ENTRIES = {
'chat-apps.mjs': '../src/chat-apps.js',
'ai.mjs': '../src/ai.js',
'jobs.mjs': '../src/jobs.js',
++ 'agents.mjs': '../src/agents.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
@@@ -80,6 +83,7 @@ const ENTRIES = {
'download.mjs': '../src/download.js',
'quest.mjs': '../src/quest.js',
'live.mjs': '../src/live.js',
++ 'player.mjs': '../src/player.js',
'streams.mjs': '../src/streams.js',
'reports.mjs': '../src/reports.js',
'inventory.mjs': '../src/inventory.js',
@@@ -101,6 +105,7 @@ const ENTRIES = {
'front-page.mjs': '../src/front-page.js',
'food.mjs': '../src/food.js',
'actor.mjs': '../src/actor.js',
++ 'codes.mjs': '../src/codes.js',
'run.mjs': '../src/run.js',
'video.mjs': '../src/video.js',
'pics.mjs': '../src/pics.js',
@@@ -111,6 +116,7 @@ const ENTRIES = {
'frame.mjs': '../src/frame.js',
'menu.mjs': '../src/menu/index.js',
'home-feed.mjs': '../src/home-feed.js',
++ 'feed.mjs': '../src/feed.js',
'service.mjs': '../src/service/index.js',
'testing.mjs': '../src/testing/index.js',
};
ddiff --git a/src/actor.js b/src/actor.js
iindex 069635d..afd5b0e 100644
---- a/src/actor.js
++++ b/src/actor.js
@@@ -109,6 +109,7 @@ function createActorClient(client, { baseUrl } = {}) {
/** A personal agent: name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, … */
create: (agent, { signal } = {}) => once({ method: 'POST', path: '/api/v1/me/agents', json: agent, signal }),
get: (agent, { signal } = {}) => call({ path: agentPath(agent), signal }),
++ sheet: (agent, { signal } = {}) => call({ path: agentPath(agent), query: { view: 'sheet' }, signal }),
update: (agent, patch, { signal } = {}) => call({ method: 'PATCH', path: agentPath(agent), json: patch, idempotent: true, signal }),
remove: (agent, { signal } = {}) => call({ method: 'DELETE', path: agentPath(agent), signal }),
/** Run it now with an input → the task it started (follow it with tasks.events). */
ddiff --git a/src/agent-sheet.js b/src/agent-sheet.js
nnew file mode 100644
iindex 0000000..5a8cc13
---- /dev/null
++++ b/src/agent-sheet.js
@@@ -0,0 +1,28 @@
++'use strict';
++/** Owner-only agent sheets through Network's fixed cross-site relay. The caller supplies their own Bearer. */
++const ACTOR = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MEMORY = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CODES = /^[a-z][a-z0-9-]{0,63}$/;
++
++function createAgentSheetClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createAgentSheetClient: pass an SDK core client');
++ const id = (value, pattern, kind) => {
++ if (!pattern.test(String(value || ''))) throw new TypeError(`invalid ${kind} id`);
++ return String(value);
++ };
++ const path = (agent) => `/api/frame/agents/${id(agent, ACTOR, 'Actor agent')}`;
++ const call = (opts) => client.json({ service: 'network', audience: 'openvibe.network', baseUrl, ...opts });
++ return {
++ sheet: (agent, { signal } = {}) => call({ path: `${path(agent)}/sheet`, signal }),
++ codesSheet: (agent, { signal } = {}) => call({ path: `/api/frame/codes-agents/${id(agent, CODES, 'coding agent')}/sheet`, signal }),
++ pause: (agent, { signal } = {}) => call({ method: 'PATCH', path: path(agent), json: { status: 'paused' }, signal, idempotent: true }),
++ resume: (agent, { signal } = {}) => call({ method: 'PATCH', path: path(agent), json: { status: 'active' }, signal, idempotent: true }),
++ memory: {
++ add: (agent, text, { signal } = {}) => call({ method: 'POST', path: `${path(agent)}/memory`, json: { text }, signal, idempotent: false }),
++ update: (agent, note, text, { signal } = {}) => call({ method: 'PATCH', path: `${path(agent)}/memory/${id(note, MEMORY, 'memory')}`, json: { text }, signal, idempotent: true }),
++ remove: (agent, note, { signal } = {}) => call({ method: 'DELETE', path: `${path(agent)}/memory/${id(note, MEMORY, 'memory')}`, signal, idempotent: true }),
++ },
++ };
++}
++
++module.exports = { createAgentSheetClient };
ddiff --git a/src/agents.js b/src/agents.js
nnew file mode 100644
iindex 0000000..98821f1
---- /dev/null
++++ b/src/agents.js
@@@ -0,0 +1,52 @@
++'use strict';
++/** Agent run snapshots, AG-UI event streams and approval responses. */
++const { OpenVibeError, isOpenVibeError } = require('./core/errors');
++const { parseSSE } = require('./realtime');
++const enc = encodeURIComponent;
++const pause = (ms, signal) => new Promise((resolve) => {
++ const t = setTimeout(resolve, ms);
++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
++});
++function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network' } = {}) {
++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ async function run(id, { signal } = {}) {
++ if (!id) throw new TypeError('agents.run: id is required');
++ try { return (await call({ path: `/api/v1/runs/${enc(id)}`, signal })).data; }
++ catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
++ }
++ async function post(id, approval, { signal } = {}) {
++ if (!id) throw new TypeError('agents.post: id is required');
++ if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
++ return (await call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ }
++ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
++ if (!id) throw new TypeError('agents.events: id is required');
++ let last = String(lastEventId || '0'), failures = 0, serverRetryMs = 0;
++ while (!(signal && signal.aborted)) {
++ let delivered = false;
++ try {
++ const res = await call({ path: `/api/v1/runs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
++ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': last } });
++ if (res.status === 204) return;
++ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { serverRetryMs = ms; } })) {
++ if (msg.id !== undefined && msg.id !== '') { if (/^\d+$/.test(msg.id) && Number(msg.id) <= Number(last)) continue; last = msg.id; }
++ let data; try { data = JSON.parse(msg.data); } catch { data = null; }
++ delivered = true; failures = 0;
++ yield { id: msg.id ?? null, event: msg.event || (data && data.type) || null, data };
++ if (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type)) return;
++ }
++ } catch (err) {
++ if (signal && signal.aborted) return;
++ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;
++ if (!isOpenVibeError(err) && !['TypeError', 'AbortError'].includes(err && err.name)) throw err;
++ }
++ failures = delivered ? 0 : failures + 1;
++ if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: event stream ${id} failed ${failures} times in a row` });
++ const backoffMs = Math.min(reconnectDelayMs * (2 ** Math.max(0, failures - 1)), 30000);
++ const waitMs = Math.min(Math.max(backoffMs, serverRetryMs), 30000);
++ await pause(waitMs, signal);
++ }
++ }
++ return { run, events, post };
++}
++module.exports = { createAgentsClient };
ddiff --git a/src/chat.js b/src/chat.js
iindex 631e39f..20e91db 100644
---- a/src/chat.js
++++ b/src/chat.js
@@@ -72,6 +72,14 @@ function createChatClient(client, defaults = {}) {
history,
/** The typed address previews on a message (chat.embed@1): [] when it has none. */
embeds: (message) => readEmbeds(message),
++ /** Compacted day, week or month memory for a person (chat.person-memory-result@1). */
++ people: {
++ memory: (username, { channel, level = 'day' } = {}, o = {}) => call({ path: `/api/v1/people/${enc(String(username))}/memory`, query: { channel, level } }, o),
++ /** Erase the signed-in person's memory and opt out. */
++ forget: (username, o = {}) => call({ method: 'DELETE', path: `/api/v1/people/${enc(String(username))}/memory`, ...once }, o),
++ /** Set the signed-in person's opt-out preference. */
++ preferences: (username, optedOut, o = {}) => call({ method: 'PUT', path: `/api/v1/people/${enc(String(username))}/memory/preferences`, json: { opted_out: !!optedOut }, ...once }, o),
++ },
/** Vibe's model comparison API, authenticated as the person. */
ask: (input, o = {}) => call({ method: 'POST', path: '/api/vibe/ask', json: input, ...once }, o),
pick: (messageId, model, o = {}) => call({ method: 'POST', path: '/api/vibe/pick', json: { message_id: messageId, model }, ...once }, o),
ddiff --git a/src/codes.js b/src/codes.js
nnew file mode 100644
iindex 0000000..bd9ef8b
---- /dev/null
++++ b/src/codes.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * openvibe-sdk/codes — OpenVibe.Codes for apps: the person's coding tasks as the Dock's Code tab lists, starts and
++ * stops them. Routes at https://openvibe.codes/api/v1; the person's own Network access token.
++ *
++ * const codes = createCodesClient(createClient({ tokenProvider }));
++ * const { tasks } = await codes.tasks.list({ limit: 8 }); // running first, codes.task-summary@1
++ * const { id, url } = await codes.tasks.create({ text: 'Add a test.', mode: 'repo', repo: 'octocat/Hello-World' });
++ * await codes.tasks.stop(id); // { ok: true }
++ *
++ * A task is its requester's own: another person's id is 404. Creates are never retried on their own (a retry would
++ * start a second task); neither is a stop (a task already ended answers 409). The person token rides every call.
++ */
++const enc = encodeURIComponent;
++
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MODES = ['create', 'openvibe', 'repo'];
++
++function createCodesClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function' || typeof client.request !== 'function') throw new TypeError('createCodesClient: pass an openvibe-sdk/core client');
++ const at = { service: 'codes', baseUrl, audience: 'openvibe.codes' };
++ const call = (opts) => client.json({ ...at, ...opts });
++ const once = (opts) => call({ ...opts, idempotencyKey: false });
++ const id = (v) => {
++ if (!TASK.test(String(v || ''))) throw new TypeError('codes: a task id is tsk_…');
++ return enc(String(v));
++ };
++
++ return {
++ tasks: {
++ /** The person's latest tasks, running first (codes.task-summary@1). */
++ list: ({ limit = 8, signal } = {}) => {
++ const n = Number(limit);
++ if (!Number.isInteger(n) || n < 1 || n > 50) throw new TypeError('codes.tasks.list: limit is 1-50');
++ return call({ path: '/api/v1/me/tasks', query: { limit: n }, signal });
++ },
++ /** Start one the Dock's way: the words, where it starts, and what it carries. → { id, url }. */
++ create: ({ text, mode, repo, attachments } = {}, { signal } = {}) => {
++ if (typeof text !== 'string' || !text.trim()) throw new TypeError('codes.tasks.create: text is the instruction in plain words');
++ if (!MODES.includes(mode)) throw new TypeError('codes.tasks.create: mode is create, openvibe or repo');
++ if (repo != null && (typeof repo !== 'string' || !repo.trim())) throw new TypeError('codes.tasks.create: repo is a repository address');
++ if (attachments != null && (!Array.isArray(attachments) || attachments.some((a) => typeof a !== 'string'))) {
++ throw new TypeError('codes.tasks.create: attachments are composer attachment ids');
++ }
++ return once({
++ method: 'POST', path: '/api/v1/tasks',
++ json: { text, mode, ...(repo ? { repo } : {}), ...(attachments ? { attachments } : {}) }, signal,
++ });
++ },
++ /** Stop the running attempt; nothing carries it on. → { ok: true }. */
++ stop: (taskId, { signal } = {}) => once({ method: 'POST', path: `/api/v1/tasks/${id(taskId)}/stop`, signal }),
++ },
++ };
++}
++
++module.exports = { createCodesClient };
ddiff --git a/src/community.js b/src/community.js
iindex fd5e67a..61d4f6c 100644
---- a/src/community.js
++++ b/src/community.js
@@@ -113,6 +113,8 @@ function createCommunityClient(client, defaults = {}) {
query: { kind: query.kind, site: query.site, q: query.q, cursor: query.cursor, limit: query.limit,
following: query.following === true || query.following === 1 ? '1' : undefined },
}, o);
++ // Named form used by new integrations; the callable form remains for existing SDK consumers.
++ feed.list = feed;
const posts = {
/**
* A status post in the Feed at once: { text, pictures?: [{ media_id, alt }] } (at most four, alt required,
ddiff --git a/src/composer.js b/src/composer.js
nnew file mode 100644
iindex 0000000..e422a23
---- /dev/null
++++ b/src/composer.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * openvibe-sdk/composer: the shared composer's client, the same on every OpenVibe site.
++ *
++ * attachments.upload(file, { onProgress, signal, filename, contentType, xhr })
++ * POST /api/v1/attachments on Media (multipart `file`), through XMLHttpRequest so upload progress is real.
++ * -> composer.attachment@1
++ * attachments.remove(id)
++ * DELETE /api/v1/attachments/<id>; true when it went, false when it was already gone.
++ * composer.prefs.get() / composer.prefs.put(prefs, rev)
++ * GET/PUT /api/v1/me/composer on Network (composer.prefs@1). A PUT sends the revision it read; a stale one is
++ * 409 composer.rev_conflict and err.problem.current is the other tab's document.
++ * mentions.search(q, kinds)
++ * GET /api/v1/mentions on Network (composer.mention@1): people the caller follows first, then public people,
++ * repositories from OpenVibe Git, first-party sites. Signed-in only; at most 8.
++ *
++ * Browser-safe. The composer page calls Network directly for prefs and mentions (every site's connect-src allows it)
++ * and Media for the bytes (the site's CSP must allow OPENVIBE_MEDIA_ORIGIN).
++ */
++const { OpenVibeError, isOpenVibeError } = require('./core/errors');
++
++const MEDIA_ORIGIN = 'https://openvibe.media';
++const NETWORK_ORIGIN = 'https://openvibe.network';
++const ATTACHMENT_MAX = 300 * 1024 * 1024;
++const enc = encodeURIComponent;
++
++function toBlob(file, contentType) {
++ if (typeof Blob !== 'undefined' && file instanceof Blob) return file;
++ if (typeof file === 'string' || file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {
++ return new Blob([file], contentType ? { type: contentType } : undefined);
++ }
++ throw new TypeError('attachments.upload: pass a File/Blob, ArrayBuffer, typed array (Buffer) or string');
++}
++
++function abortError() {
++ return new OpenVibeError({ code: 'sdk.aborted', message: 'the upload was aborted' });
++}
++
++function createComposerClient(client, { mediaBaseUrl, networkBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createComposerClient: an SDK client is required');
++ const net = (opts) => client.json({ service: 'network', audience: 'openvibe.network', ...(networkBaseUrl ? { baseUrl: networkBaseUrl } : {}), ...opts });
++
++ /** The Media token for an XHR upload (the XHR cannot borrow the fetch client's request loop). */
++ async function mediaToken() {
++ const o = client.options || {};
++ if (o.token != null) return o.token;
++ if (typeof o.getToken === 'function') return (await o.getToken({ service: 'media', audience: 'openvibe.media' })) || null;
++ return null;
++ }
++
++ const attachments = {
++ /**
++ * One file -> composer.attachment@1. Progress is the browser's upload progress (XHR), not a guess.
++ * `xhr` injects an XMLHttpRequest implementation (tests, a non-browser shell); otherwise the global one.
++ */
++ upload(file, { onProgress, signal, filename, contentType, xhr } = {}) {
++ return new Promise((resolve, reject) => {
++ const XHR = xhr || (typeof XMLHttpRequest !== 'undefined' ? XMLHttpRequest : null);
++ if (!XHR) return reject(new OpenVibeError({ code: 'sdk.xhr_unavailable', message: 'attachments.upload needs XMLHttpRequest (pass { xhr })' }));
++ let blob;
++ try { blob = toBlob(file, contentType); } catch (err) { return reject(err); }
++ if (blob.size > ATTACHMENT_MAX) {
++ return reject(new OpenVibeError({ code: 'media.attachment.too_large', status: 413, message: `a file may be at most ${ATTACHMENT_MAX / (1024 * 1024)} MB` }));
++ }
++ const url = client.url({ service: 'media', ...(mediaBaseUrl ? { baseUrl: mediaBaseUrl } : {}), path: '/api/v1/attachments' });
++ Promise.resolve(url).then(async (target) => {
++ if (signal && signal.aborted) return reject(abortError());
++ const fd = new FormData();
++ fd.append('file', blob, filename || (file && file.name) || 'file');
++ const req = new XHR();
++ let settled = false;
++ const finish = (fn, v) => { if (settled) return; settled = true; cleanup(); fn(v); };
++ const offAbort = signal && typeof signal.addEventListener === 'function' ? () => signal.removeEventListener('abort', onAbort) : () => {};
++ function onAbort() { try { req.abort(); } catch { /* */ } finish(reject, abortError()); }
++ const cleanup = () => { offAbort(); };
++ req.open('POST', target);
++ req.setRequestHeader('Accept', 'application/json');
++ const token = await mediaToken();
++ if (token) req.setRequestHeader('Authorization', `Bearer ${token}`);
++ if (req.upload && typeof onProgress === 'function') {
++ req.upload.onprogress = (e) => onProgress({ loaded: e.loaded || 0, total: e.total || blob.size, lengthComputable: e.lengthComputable !== false });
++ }
++ req.onload = () => {
++ let body = null;
++ try { body = req.responseText ? JSON.parse(req.responseText) : null; } catch { body = req.responseText || null; }
++ if (req.status >= 200 && req.status < 300) return finish(resolve, body);
++ const err = OpenVibeError.fromResponse({ status: req.status, body, method: 'POST', url: target });
++ if (body && body.current) err.current = body.current;
++ finish(reject, err);
++ };
++ req.onerror = () => finish(reject, new OpenVibeError({ code: 'sdk.network_error', message: 'the upload could not reach Media', method: 'POST', url: target }));
++ req.ontimeout = () => finish(reject, new OpenVibeError({ code: 'sdk.timeout', message: 'the upload timed out', method: 'POST', url: target }));
++ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
++ try { req.send(fd); } catch (err) { finish(reject, err); }
++ }).catch(reject);
++ });
++ },
++
++ /** DELETE /api/v1/attachments/<id>: true when it went, false when it was already gone. */
++ async remove(id) {
++ if (!id) throw new TypeError('attachments.remove: pass the attachment id');
++ try {
++ await client.json({ service: 'media', ...(mediaBaseUrl ? { baseUrl: mediaBaseUrl } : {}), audience: 'openvibe.media', method: 'DELETE', path: `/api/v1/attachments/${enc(id)}` });
++ return true;
++ } catch (err) {
++ if (isOpenVibeError(err) && err.status === 404) return false;
++ throw err;
++ }
++ },
++ };
++
++ const composer = {
++ prefs: {
++ /** composer.prefs@1, with its rev. */
++ get: () => net({ path: '/api/v1/me/composer' }),
++ /**
++ * composer.prefs@1 in (rev is the one you read; 0 for a new person), the new document out.
++ * A stale rev is 409 composer.rev_conflict with err.problem.current (and err.current).
++ */
++ put(prefs, rev) {
++ if (!prefs || typeof prefs !== 'object') throw new TypeError('composer.prefs.put: pass the prefs object');
++ if (!Number.isInteger(rev) || rev < 0) throw new TypeError('composer.prefs.put: pass the revision you read (0 for new)');
++ return net({ method: 'PUT', path: '/api/v1/me/composer', json: { ...prefs, rev } });
++ },
++ },
++ };
++
++ const mentions = {
++ /** @ menu entries (composer.mention@1[]). kinds: person | repo | site | file; omitted = all. */
++ async search(q, kinds) {
++ const list = Array.isArray(kinds) ? kinds : (kinds ? String(kinds).split(',') : []);
++ const data = await net({ path: '/api/v1/mentions', query: { q: q || undefined, kinds: list.length ? list.join(',') : undefined } });
++ return (data && Array.isArray(data.mentions)) ? data.mentions : [];
++ },
++ };
++
++ return { attachments, composer, mentions, MEDIA_ORIGIN, NETWORK_ORIGIN };
++}
++
++module.exports = { createComposerClient, MEDIA_ORIGIN, NETWORK_ORIGIN, ATTACHMENT_MAX };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 9328ae0..0c729d7 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.147.0';
++const SDK_VERSION = '0.148.0';
module.exports = {
createClient,
ddiff --git a/src/exchange.js b/src/exchange.js
iindex 5f8d133..214af17 100644
---- a/src/exchange.js
++++ b/src/exchange.js
@@@ -47,6 +47,7 @@ const crypto = require('node:crypto');
const EXCHANGE_BASE_URL = 'https://api.openvibe.exchange';
/** The Network's origin: the user-owned AI offers live there. */
const NETWORK_BASE_URL = 'https://api.openvibe.network';
++const CATALOG_SEARCH_URL = 'https://openvibe.exchange';
/** How far a signed webhook's timestamp may be from this server's clock, in seconds. */
const SIGNATURE_TOLERANCE_SEC = 300;
@@@ -664,6 +665,7 @@ function createExchangeClient({
}
module.exports = {
++ createCatalogSearchClient,
createExchangeClient,
ExchangeError,
verifyWebhook,
@@@ -673,3 +675,18 @@ module.exports = {
DEFAULT_PATHS,
RETRY_STATUS,
};
++
++/** A public, keyless reader for Exchange's model and provider search. */
++function createCatalogSearchClient({ baseUrl = CATALOG_SEARCH_URL, fetchImpl = globalThis.fetch } = {}) {
++ return {
++ async search(q, filters = {}, { signal } = {}) {
++ if (typeof q !== 'string' || q.length > 200) throw new TypeError('q must be a string of at most 200 characters');
++ const url = new URL('/api/v1/search', baseUrl);
++ for (const [key, value] of Object.entries({ ...filters, q })) if (value != null && value !== '') url.searchParams.set(key, String(value));
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
++ const body = await res.json();
++ if (!res.ok) throw new ExchangeError(body?.detail || body?.error || `Search answered ${res.status}`, { status: res.status, body });
++ return body;
++ },
++ };
++}
ddiff --git a/src/feed.js b/src/feed.js
nnew file mode 100644
iindex 0000000..4452435
---- /dev/null
++++ b/src/feed.js
@@@ -0,0 +1,15 @@
++'use strict';
++
++/** Browser-safe reader for the public, cross-site Feed cards on OpenVibe.Community. */
++function createFeedClient(client, { communityBaseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createFeedClient: pass an openvibe-sdk/core client');
++ return {
++ /** Read one page of cards; pass next_cursor back as cursor to continue. */
++ list: ({ kind, cursor } = {}, { signal } = {}) => client.json({
++ service: 'community', baseUrl: communityBaseUrl, audience: 'openvibe.community',
++ path: '/api/v1/feed', query: { kind, cursor }, signal,
++ }),
++ };
++}
++
++module.exports = { createFeedClient };
ddiff --git a/src/home-feed.js b/src/home-feed.js
iindex aa26817..1de6f9f 100644
---- a/src/home-feed.js
++++ b/src/home-feed.js
@@@ -1,10 +1,11 @@
'use strict';
--/** The menu's portable Home layout, network day counts and personal wall. Pass a user-token core client;
-- * contribute() needs an app token with the person's community.pulse.contribute grant. */
++/** The menu's portable Home layout, network day counts, what people do now and personal wall. Pass a user-token core
++ * client; contribute() needs an app token with the person's community.pulse.contribute grant. */
function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {}) {
if (!client || typeof client.json !== 'function') throw new TypeError('createHomeFeedClient: pass an openvibe-sdk/core client');
const network = (opts) => client.json({ service: 'network', baseUrl: networkBaseUrl, audience: 'openvibe.network', ...opts });
const community = (opts) => client.json({ service: 'community', baseUrl: communityBaseUrl, audience: 'openvibe.community', ...opts });
++ const limitQuery = (limit) => (limit == null ? undefined : { limit });
return {
home: {
get: ({ signal } = {}) => network({ path: '/api/v1/me/home-screen', signal }),
@@@ -12,6 +13,10 @@ function createHomeFeedClient(client, { networkBaseUrl, communityBaseUrl } = {})
},
network: {
today: ({ signal } = {}) => network({ path: '/api/v1/frame/today', auth: false, signal }),
++ // The signed-out front door's list: Report's public aggregate, no credentials sent.
++ popular: ({ limit, signal } = {}) => network({ path: '/api/v1/home/popular', query: limitQuery(limit), auth: false, signal }),
++ // The signed-in person's own suggestions; Network computes them in the caller's request.
++ tryNext: ({ limit, signal } = {}) => network({ path: '/api/v1/me/try-next', query: limitQuery(limit), signal }),
},
feed: {
list: ({ for: audience = 'me', cursor, signal } = {}) => community({ path: '/api/v1/pulse', query: { for: audience, cursor }, signal }),
ddiff --git a/src/player.js b/src/player.js
nnew file mode 100644
iindex 0000000..12d0f5c
---- /dev/null
++++ b/src/player.js
@@@ -0,0 +1,13 @@
++'use strict';
++/** Browser facade for Shared's portable live player. */
++function host() { return typeof window === 'undefined' ? null : window.OVOverlay || null; }
++function current() { return host()?.current() || null; }
++function handoff(url) { return host()?.handoffUrl(url) || url; }
++function on(listener) {
++ if (typeof listener !== 'function') throw new TypeError('listener must be a function');
++ if (typeof window === 'undefined') return () => {};
++ const receive = (event) => listener(event.detail);
++ window.addEventListener('openvibe:player', receive);
++ return () => window.removeEventListener('openvibe:player', receive);
++}
++module.exports = { current, handoff, on };
ddiff --git a/src/sso/index.js b/src/sso/index.js
iindex 396eed4..4fd77c3 100644
---- a/src/sso/index.js
++++ b/src/sso/index.js
@@@ -11,7 +11,8 @@
* networkUrl: 'https://openvibe.network', networkInternalUrl: 'http://127.0.0.1:4000',
* secureCookies: true,
* });
-- * app.use('/auth', sso.router(express)); // /login /callback /fedcm /logout /me /refresh
++ * app.use('/auth', sso.router(express)); // /login /callback /fedcm /logout /me /refresh /health /handoff
++ * app.use(sso.handoff()); // ?ov_handoff= arrival: redeem before the page renders
* app.use(sso.optionalAuth()); // req.user, req.token when a valid session is presented
* app.get('/private', sso.requireAuth(), handler); // 401 JSON without a session
*
@@@ -35,6 +36,10 @@
* GET /health → { ok, client: 'ok' | 'invalid' | 'unknown' }: does the Network accept this site's OAuth client?
* (a token request with a code that cannot exist: invalid_grant means the client is fine,
* invalid_client means its id or secret is wrong; cached 10 minutes; 503 only for 'invalid')
++ * POST /handoff → { ticket, expires_in }: a one-time 60 s ticket for a link to another OpenVibe site
++ * (the caller's session proves the person; body: the destination's client_id + redirect_uri).
++ * Mount sso.handoff() early: an arrival carrying ?ov_handoff= redeems for this site's session
++ * before the page renders — no extra round trip, never switches a live session, never wakes a guest.
*
* When a sign-in cannot finish (0.73.0): a lost or expired flow (the state cookie is gone or does not match) and a code
* the Network no longer accepts (invalid_grant: used twice, or too old) start the sign-in once more by themselves, with
@@@ -107,6 +112,24 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
++/**
++ * The cross-site handoff ticket arrives as ?ov_handoff=<ticket> on a plain link (Shared's handoffLinks
++ * carries it; the source minted it at its own /auth/handoff). It is single-use and short-lived, so the
++ * first thing a redeem does is scrub it from the address bar with a same-site redirect.
++ */
++const HANDOFF_PARAM = 'ov_handoff';
++const TICKET_RE = /^ovh_[A-Za-z0-9_-]{20,200}$/;
++
++/** Remove the handoff ticket from a path+query URL, keeping everything else. Never throws. */
++function stripHandoffParam(target) {
++ try {
++ const u = new URL(String(target || '/'), 'https://site.invalid');
++ u.searchParams.delete(HANDOFF_PARAM);
++ const q = u.searchParams.toString();
++ return `${u.pathname}${q ? `?${q}` : ''}${u.hash}`;
++ } catch { return '/'; }
++}
++
/** A JWT's payload WITHOUT checking its signature: only to pre-check a FedCM nonce before the Network verifies it. */
function decodeJwtPayload(token) {
if (typeof token !== 'string') return null;
@@@ -238,6 +261,32 @@ function createSsoClient(opts = {}) {
}
const SIGN_IN_AGAIN = { title: 'Sign-in didn\'t finish', text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.' };
++ /** A JSON POST to the Network: the internal URL first, the public one if it cannot be reached. */
++ async function networkPost(path, body, bearer) {
++ let lastErr = null;
++ for (const base of [...new Set([networkInternalUrl, networkUrl])]) {
++ try {
++ const res = await fetchImpl(`${base}${path}`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ throw Object.assign(new Error(data.error_description || data.error || `request failed (${res.status})`), {
++ status: res.status, error: data.error || 'invalid_grant', error_description: data.error_description || null,
++ });
++ }
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err; // the request itself is bad: another base will not help
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
/** A token grant at the Network: the internal URL first, the public one if it cannot be reached. */
async function tokenGrant(body) {
let lastErr = null;
@@@ -403,6 +452,31 @@ function createSsoClient(opts = {}) {
return res.status(502).json({ error: 'OpenVibe.Network could not be reached' });
}
},
++
++ /**
++ * Mint a one-time cross-site handoff ticket for a link to another OpenVibe site. The caller's
++ * session proves the person (Bearer or cookie); the ticket is bound to the destination the caller
++ * names and lives 60 seconds. Shared's handoffLinks calls this same-origin on intent.
++ */
++ async handoffMint(req, res) {
++ const token = extractToken(req);
++ if (!await verify(token)) return res.status(401).json({ error: 'Sign in required' });
++ const { client_id, redirect_uri } = (req.body || {});
++ if (typeof client_id !== 'string' || !client_id || typeof redirect_uri !== 'string' || !redirect_uri) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'client_id and redirect_uri are required' });
++ }
++ try {
++ const data = await networkPost('/api/v1/handoff/tickets', { client_id, redirect_uri }, token);
++ if (!data || typeof data.ticket !== 'string' || !TICKET_RE.test(data.ticket)) {
++ throw Object.assign(new Error('Network issued no ticket'), { status: 502 });
++ }
++ return res.json({ ticket: data.ticket, expires_in: data.expires_in || 60 });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(err.status).json({ error: err.error || 'invalid_request', error_description: err.error_description || err.message });
++ log.error(`[${site} auth] handoff mint failed: ${err.message}`);
++ return res.status(502).json({ error: 'OpenVibe.Network could not be reached' });
++ }
++ },
};
let health = null; // { client, at }: the last check, or the last sign-in's verdict
@@@ -442,6 +516,7 @@ function createSsoClient(opts = {}) {
r.get('/me', wrap(handlers.me));
r.post('/refresh', wrap(handlers.refresh));
r.get('/health', wrap(handlers.health));
++ r.post('/handoff', json, badJson, wrap(handlers.handoffMint));
return r;
}
@@@ -460,10 +535,45 @@ function createSsoClient(opts = {}) {
return (req, res, nextFn) => opt(req, res, () => (req.user ? nextFn() : res.status(401).json({ error: 'Sign in required' })));
}
-- return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, checkClient, redirectUri, audience };
++ /**
++ * Arrival with ?ov_handoff=<ticket>: redeem it for this site's session before the page renders, so a
++ * link from another OpenVibe site lands signed in with no extra round trip. Mount early (before your
++ * pages): `app.use(sso.handoff())`. Rules, all enforced here, never in the page:
++ * - GET navigations only, and only when this browser has NO session here (never switch an account);
++ * - never when the browser carries this site's guest hint (a guest stays a guest, no Network call);
++ * - any redeem or exchange failure renders the page as a guest; the ticket is always scrubbed from
++ * the address bar with a same-site redirect, so a bearer ticket never sits in history.
++ */
++ function handoff() {
++ return (req, res, nextFn) => {
++ (async () => {
++ const ticket = (req.query || {})[HANDOFF_PARAM];
++ if (req.method !== 'GET' || typeof ticket !== 'string' || !TICKET_RE.test(ticket)) return nextFn();
++ if (parseCookies(req)[HINT_COOKIE] === 'guest') return nextFn();
++ if (await verify(extractToken(req))) return nextFn();
++ const target = req.originalUrl || req.url || '/';
++ const clean = stripHandoffParam(target);
++ try {
++ const data = await networkPost('/api/v1/handoff/redeem', {
++ ticket, client_id: opts.clientId, client_secret: opts.clientSecret, redirect_uri: redirectUri,
++ });
++ if (!data || typeof data.code !== 'string' || !data.code) throw new Error('Network issued no code');
++ const tokens = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: data.code });
++ if (!tokens || typeof tokens.access_token !== 'string' || !tokens.access_token) throw new Error('Network issued no session');
++ setSession(res, tokens.access_token, tokens.refresh_token);
++ } catch (err) {
++ log.warn(`[${site} auth] handoff arrival failed: ${err.message}`);
++ }
++ return res.redirect(clean);
++ })().catch(nextFn);
++ };
++ }
++
++ return { router, handlers, handoff, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, checkClient, redirectUri, audience };
}
module.exports = {
-- createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies, failurePage,
++ createSsoClient, sanitizeNext, withParam, stripHandoffParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies, failurePage,
++ HANDOFF_PARAM,
COOKIES: Object.freeze({ access: ACCESS_COOKIE, refresh: REFRESH_COOKIE, hint: HINT_COOKIE }),
};
ddiff --git a/src/wallet.js b/src/wallet.js
iindex 9efdf6e..e4b1b70 100644
---- a/src/wallet.js
++++ b/src/wallet.js
@@@ -1,9 +1,10 @@
'use strict';
--/** Personal bearer wallet/store facade. Amounts and ownership are enforced by Billing and Inventory. */
--function createWalletClient(client, { billingUrl, inventoryUrl } = {}) {
++/** Personal bearer wallet/store facade. Amounts and ownership are enforced by their ledger services. */
++function createWalletClient(client, { billingUrl, inventoryUrl, networkUrl } = {}) {
if (!client || typeof client.json !== 'function') throw new TypeError('createWalletClient requires an SDK core client');
-- const call = (service, path, opts = {}) => client.json({ service, baseUrl: service === 'billing' ? billingUrl : inventoryUrl,
++ const urls = { billing: billingUrl, inventory: inventoryUrl, network: networkUrl };
++ const call = (service, path, opts = {}) => client.json({ service, baseUrl: urls[service],
audience: `openvibe.${service}`, path, credentials: 'omit', ...opts });
const key = (v) => {
if (typeof v !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(v)) throw new TypeError('An idempotency_key of 8–128 letters, digits, dot, colon, underscore or dash is required');
@@@ -11,12 +12,38 @@ function createWalletClient(client, { billingUrl, inventoryUrl } = {}) {
};
const whole = (v) => { if (!Number.isSafeInteger(v) || v < 1) throw new TypeError('Choose a positive whole amount'); return v; };
const itemId = (v) => { if (!/^itd_[0-9A-HJKMNP-TV-Z]{26}$/.test(v || '')) throw new TypeError('An item id is an itd_ id'); return encodeURIComponent(v); };
++ const cursor = (v, currency) => {
++ if (v == null) return v;
++ if (currency === 'vibes') {
++ if (typeof v !== 'string' || !/^txn_[0-9A-HJKMNP-TV-Z]{26}$/.test(v)) throw new TypeError('vibes cursor is a txn_ id');
++ } else if (!Number.isSafeInteger(Number(v)) || Number(v) <= 0 || !/^[0-9]+$/.test(String(v))) {
++ throw new TypeError('cursor is a positive move id');
++ }
++ return v;
++ };
++ const streamer = (v) => { if (!Number.isSafeInteger(v) || v <= 0) throw new TypeError('streamer is a positive user id'); return v; };
// The BODY key is durable in both services. Retries keep it; the SDK must not add an unrelated header key.
const write = (service, path, json, { signal } = {}) => call(service, path, { method: 'POST', json,
idempotencyKey: false, idempotent: true, signal });
return {
wallet: {
get: ({ signal } = {}) => call('billing', '/api/v1/me/wallet', { signal }),
++ changes: ({ since_vibes, since_opencoins, since_channel_points, streamer: channel, limit, signal } = {}) => {
++ if (limit != null && (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)) throw new TypeError('limit is 1 to 500');
++ return call('network', '/api/wallet/changes', { query: {
++ since_vibes: cursor(since_vibes, 'vibes'), since_opencoins: cursor(since_opencoins, 'opencoins'),
++ since_channel_points: cursor(since_channel_points, 'channel-points'),
++ streamer: channel == null ? undefined : streamer(channel), limit,
++ }, signal });
++ },
++ markRead: ({ currency, cursor: at, streamer: channel }, { signal } = {}) => {
++ if (!['vibes', 'opencoins', 'channel-points'].includes(currency)) throw new TypeError('currency is vibes, opencoins or channel-points');
++ if (at == null) throw new TypeError('cursor is required');
++ if (currency === 'channel-points' && channel == null) throw new TypeError('streamer is required for channel points');
++ return call('network', '/api/wallet/changes/read', { method: 'PUT', json: {
++ currency, cursor: cursor(at, currency), ...(currency === 'channel-points' ? { streamer: streamer(channel) } : {}),
++ }, signal });
++ },
topUp: ({ provider, amount_cents, idempotency_key }, options) => write('billing', '/api/v1/me/wallet/topups',
{ provider, amount_cents: whole(amount_cents), idempotency_key: key(idempotency_key) }, options),
},
ddiff --git a/test/agent-sheet.test.js b/test/agent-sheet.test.js
nnew file mode 100644
iindex 0000000..dccb129
---- /dev/null
++++ b/test/agent-sheet.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++const assert = require('assert');
++const { createAgentSheetClient } = require('../src/agent-sheet');
++const { createActorClient } = require('../src/actor');
++const seen = [];
++const client = createAgentSheetClient({ json: async (request) => { seen.push(request); return { ok: true }; } });
++const agent = 'agt_00000000000000000000000000';
++const memory = 'mem_00000000000000000000000000';
++
++(async () => {
++ await client.sheet(agent);
++ await client.codesSheet('codex');
++ await client.pause(agent);
++ await client.resume(agent);
++ await client.memory.add(agent, 'A fact');
++ await client.memory.update(agent, memory, 'New fact');
++ await client.memory.remove(agent, memory);
++ await createActorClient({ json: async (request) => { seen.push(request); return { ok: true }; }, request: async () => ({}) }).me.agents.sheet(agent);
++ assert.deepStrictEqual(seen.map((r) => [r.method || 'GET', r.path]), [
++ ['GET', `/api/frame/agents/${agent}/sheet`],
++ ['GET', '/api/frame/codes-agents/codex/sheet'],
++ ['PATCH', `/api/frame/agents/${agent}`],
++ ['PATCH', `/api/frame/agents/${agent}`],
++ ['POST', `/api/frame/agents/${agent}/memory`],
++ ['PATCH', `/api/frame/agents/${agent}/memory/${memory}`],
++ ['DELETE', `/api/frame/agents/${agent}/memory/${memory}`],
++ ['GET', `/api/v1/me/agents/${agent}`],
++ ]);
++ assert.ok(seen.slice(0, -1).every((r) => r.service === 'network' && r.audience === 'openvibe.network'));
++ assert.deepStrictEqual(seen.at(-1).query, { view: 'sheet' });
++ assert.deepStrictEqual([seen[2].json, seen[3].json], [{ status: 'paused' }, { status: 'active' }]);
++ assert.throws(() => client.sheet('../other'), TypeError);
++ assert.throws(() => client.codesSheet('../other'), TypeError);
++ assert.throws(() => client.memory.remove(agent, '../other'), TypeError);
++ console.log('agent sheet reader: fixed Network paths and validated IDs');
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/agents.test.js b/test/agents.test.js
nnew file mode 100644
iindex 0000000..432258f
---- /dev/null
++++ b/test/agents.test.js
@@@ -0,0 +1,24 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { run, stubServer, send } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createAgentsClient } = require('../src/agents');
++run([
++ ['run snapshot, post approval, and resumable AG-UI SSE', async () => {
++ let calls = 0; const srv = await stubServer((req,res) => {
++ if (req.url.endsWith('/approvals')) return send(res,200,{accepted:true});
++ if (req.url.endsWith('/events')) { calls++; assert.equal(req.headers['last-event-id'], calls === 1 ? '4' : '5'); res.writeHead(200,{'Content-Type':'text/event-stream'}); res.write(`id: ${calls===1?'5':'6'}\nevent: ${calls===1?'STEP_STARTED':'RUN_FINISHED'}\ndata: ${JSON.stringify({type:calls===1?'STEP_STARTED':'RUN_FINISHED',timestamp:'2026-11-11T00:00:00Z'})}\n\n`); return res.end(); }
++ return send(res,200,{id:'run_a',status:'running'});
++ });
++ try { const agents=createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'}));
++ assert.equal((await agents.run('run_a')).id,'run_a'); assert.deepEqual(await agents.post('run_a',{id:'approval_1',action:'approve'}),{accepted:true});
++ const got=[]; for await (const e of agents.events('run_a',{lastEventId:4,reconnectDelayMs:1})) got.push([e.id,e.event]);
++ assert.deepEqual(got,[['5','STEP_STARTED'],['6','RUN_FINISHED']]); assert.equal(srv.requests[3].headers['last-event-id'],'5');
++ } finally { await srv.close(); }
++ }],
++ ['reconnect retries use exponential backoff', async () => {
++ let calls=0; const srv=await stubServer((req,res)=>{ calls++; if(calls<4) return send(res,503,{code:'temporarily.unavailable'}); res.writeHead(200,{'Content-Type':'text/event-stream'}); res.end(`id: 1\nevent: RUN_FINISHED\ndata: ${JSON.stringify({type:'RUN_FINISHED',timestamp:1794355200000})}\n\n`); });
++ try { const agents=createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'})); const start=Date.now(); const got=[]; for await(const e of agents.events('run_a',{reconnectDelayMs:10,maxReconnects:5})) got.push(e.id); assert.deepEqual(got,['1']); assert.ok(Date.now()-start>=65,`backoff elapsed ${Date.now()-start}ms`); assert.equal(calls,4); }
++ finally { await srv.close(); }
++ }],
++]);
ddiff --git a/test/app-signin.test.js b/test/app-signin.test.js
iindex 9c29a7e..32e36e8 100644
---- a/test/app-signin.test.js
++++ b/test/app-signin.test.js
@@@ -117,8 +117,11 @@ run([
assert.equal(me.body.user.subject, USER);
assert.deepEqual(me.body.user.capabilities, ['media.object.read']);
assert.equal(me.body.user.app, `app:${APP}`);
-- assert.ok(!JSON.stringify(me.body).includes('eyJ'), 'no token in the answer');
-- assert.ok(!JSON.stringify(ok.cookies).includes('eyJ'), 'no token in the cookie');
++ // A token is a JWT's shape (three base64url parts, the first "eyJ…"), not the letters "eyJ": a random session id
++ // can hold those three letters, and the bare substring failed now and then on the slow macOS runners (2026-11-11).
++ const JWT = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
++ assert.ok(!JWT.test(JSON.stringify(me.body)), 'no token in the answer');
++ assert.ok(!JWT.test(JSON.stringify(ok.cookies)), 'no token in the cookie');
// Signed out: no user.
const out = fakeRes();
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 1bb133a..558f2da 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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', './chat-apps', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './design', './download', './events', './exchange', './food', './git', './govern', './host', './identity', './inventory', './limits', './link', './live', './menu', './news', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './shoes', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './codes', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './design', './download', './events', './exchange', './food', './git', './govern', './host', './identity', './inventory', './limits', './link', './live', './menu', './news', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './shoes', './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
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.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`);
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex c304d88..e3d9eec 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -20,6 +20,20 @@ async function chatStub() {
}
run([
++ ['person memory reader and privacy controls use the person API', async () => {
++ const srv = await chatStub();
++ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 'person-token' }));
++ await chat.people.memory('alice', { channel: 'streamer', level: 'week' });
++ await chat.people.forget('alice');
++ await chat.people.preferences('alice', false);
++ assert.deepEqual(srv.requests.map(r => [r.method, r.url]), [
++ ['GET', '/api/v1/people/alice/memory?channel=streamer&level=week'],
++ ['DELETE', '/api/v1/people/alice/memory'],
++ ['PUT', '/api/v1/people/alice/memory/preferences'],
++ ]);
++ assert.deepEqual(JSON.parse(String(srv.requests[2].body)), { opted_out: false });
++ await srv.close();
++ }],
['embeds reads chat.embed@1 and returns the typed shape, dropping what is not one', async () => {
const chat = createChatClient(createClient({ baseUrls: { chat: 'http://127.0.0.1:9' }, token: 'person-token' }));
const link = { kind: 'link', url: 'https://example.com/a', site_name: 'Example', title: 'A story', description: 'What happened', image: null, width: 640, height: 360 };
ddiff --git a/test/codes.test.js b/test/codes.test.js
nnew file mode 100644
iindex 0000000..8214401
---- /dev/null
++++ b/test/codes.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++// openvibe-sdk/codes: the Dock's Code tab calls (list, create, stop) through a real core client against a local stub.
++const assert = require('assert');
++const http = require('http');
++const { createClient } = require('../src/core');
++const { createCodesClient } = require('../src/codes');
++
++const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const summary = (over = {}) => ({
++ id: TASK, title: 'Add a test', status: 'running', mode: 'repo', repo: 'https://github.com/octocat/Hello-World',
++ started_at: '2026-11-10T10:00:00.000Z', updated_at: '2026-11-10T10:04:00.000Z', steps: 2,
++ url: `https://openvibe.codes/tasks/${TASK}`, preview_url: null, pr_url: null, ...over,
++});
++
++(async () => {
++ const seen = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, body });
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/api/v1/me/tasks?limit=8' && req.method === 'GET') return json(200, { tasks: [summary()] });
++ if (req.url === '/api/v1/tasks' && req.method === 'POST') {
++ const b = JSON.parse(body);
++ if (b.mode === 'create') return json(200, { id: null, url: 'https://openvibe.space/auth/login?next=%2Fspaces%2Fnew' });
++ return json(201, { id: TASK, url: `https://openvibe.codes/tasks/${TASK}` });
++ }
++ if (req.url === `/api/v1/tasks/${TASK}/stop` && req.method === 'POST') return json(200, { ok: true });
++ return json(404, { code: 'codes.task_not_found' });
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const codes = createCodesClient(createClient({ baseUrls: { codes: base }, token: 'person-token' }));
++ try {
++ const listed = await codes.tasks.list({ limit: 8 });
++ assert.deepStrictEqual(listed, { tasks: [summary()] });
++ const created = await codes.tasks.create({ text: 'Add a test.', mode: 'repo', repo: 'octocat/Hello-World' });
++ assert.deepStrictEqual(created, { id: TASK, url: `https://openvibe.codes/tasks/${TASK}` });
++ const space = await codes.tasks.create({ text: 'A bakery site.', mode: 'create' });
++ assert.strictEqual(space.id, null);
++ assert.ok(space.url.startsWith('https://openvibe.space'));
++ assert.deepStrictEqual(await codes.tasks.stop(TASK), { ok: true });
++
++ const posted = JSON.parse(seen.find((s) => s.url === '/api/v1/tasks' && JSON.parse(s.body).mode === 'repo').body);
++ assert.deepStrictEqual(posted, { text: 'Add a test.', mode: 'repo', repo: 'octocat/Hello-World' });
++ assert.ok(seen.every((s) => s.auth === 'Bearer ' + 'person-token'), 'the person token rides every call');
++ assert.throws(() => codes.tasks.list({ limit: 0 }), /limit is 1-50/);
++ assert.throws(() => codes.tasks.create({ text: '', mode: 'repo' }), /text is the instruction/);
++ assert.throws(() => codes.tasks.create({ text: 'Hi', mode: 'teleport' }), /mode is create/);
++ assert.throws(() => codes.tasks.create({ text: 'Hi', mode: 'repo', repo: ' ' }), /repo is a repository/);
++ assert.throws(() => codes.tasks.create({ text: 'Hi', mode: 'repo', attachments: [42] }), /attachment ids/);
++ assert.throws(() => codes.tasks.stop('nope'), /tsk_/);
++ console.log(' ok codes tasks: list, create (repo and create), stop, and every validation');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/community.test.js b/test/community.test.js
iindex 81f35bd..9443c4e 100644
---- a/test/community.test.js
++++ b/test/community.test.js
@@@ -161,7 +161,7 @@ run([
const sdk = require('..');
const community = sdk.community.createCommunityClient(sdk.createClient({ baseUrls: { community: srv.url } }));
const query = { kind: 'stream', site: 'live', q: 'broadcast', following: 1, limit: 1 };
-- const first = await community.feed(query);
++ const first = await community.feed.list(query);
assert.deepEqual(first, { items: [], next_cursor: 'opaque-cursor' });
const second = await community.feed({ ...query, cursor: first.next_cursor });
assert.deepEqual(second, { items: [item], next_cursor: null });
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 0000000..c0f935c
---- /dev/null
++++ b/test/composer.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/** The shared composer client: XHR upload with real progress, abort, remove, prefs with the revision, mentions. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createComposerClient } = require('../src/composer');
++
++function fakeXHR({ never = false } = {}) {
++ return class FakeXHR {
++ constructor() { this.upload = {}; this.headers = {}; this.sent = false; }
++ open(method, url) { this.method = method; this.url = url; }
++ setRequestHeader(k, v) { this.headers[k.toLowerCase()] = v; }
++ send(body) {
++ this.sent = true;
++ this.body = body;
++ if (never) return;
++ fetch(this.url, { method: this.method, headers: this.headers, body }).then(async (r) => {
++ this.status = r.status;
++ this.responseText = await r.text();
++ this.upload.onprogress?.({ loaded: 1, total: 1, lengthComputable: true });
++ this.onload?.();
++ }).catch((err) => this.onerror?.(err));
++ }
++ abort() { this.aborted = true; }
++ };
++}
++
++run([
++ ['the ESM entry exports the same client', async () => {
++ const esm = await import('../esm/composer.mjs');
++ assert.equal(typeof esm.createComposerClient, 'function');
++ assert.equal(esm.MEDIA_ORIGIN, 'https://openvibe.media');
++ }],
++
++ ['upload posts one multipart file through XHR, with the token and progress', async () => {
++ const srv = await stubServer((req, res) => send(res, 201, {
++ id: 'att_01JAB2C3D4E5F6G7H8J9K0MNPQ', kind: 'image', name: 'shot.png', size: 3, mime: 'image/png',
++ url: 'https://openvibe.media/a/att_01JAB2C3D4E5F6G7H8J9K0MNPQ/shot.png', thumb: null, w: 1, h: 1,
++ expires_at: '2026-11-11T12:00:00.000Z',
++ }));
++ const composer = createComposerClient(createClient({ baseUrls: { media: srv.url }, token: 'person-token' }));
++ const progress = [];
++ const out = await composer.attachments.upload(new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }), {
++ filename: 'shot.png', onProgress: (p) => progress.push(p), xhr: fakeXHR(),
++ });
++ assert.equal(out.id, 'att_01JAB2C3D4E5F6G7H8J9K0MNPQ');
++ assert.equal(out.kind, 'image');
++ const r = srv.requests[0];
++ assert.equal(r.method, 'POST');
++ assert.equal(r.url, '/api/v1/attachments');
++ assert.equal(r.headers.authorization, 'Bearer person-token');
++ assert.match(r.headers['content-type'], /^multipart\/form-data; boundary=/);
++ const text = r.body.toString('latin1');
++ assert.match(text, /name="file"; filename="shot.png"/);
++ assert.match(text, /content-type: image\/png/i);
++ assert.ok(progress.length >= 1 && progress.every((p) => p.loaded <= p.total && p.total === 1), 'real XHR progress');
++ await srv.close();
++ }],
++
++ ['a getToken client supplies the upload token; a signal aborts', async () => {
++ const composer = createComposerClient(createClient({ baseUrls: { media: 'http://127.0.0.1:9' }, getToken: async ({ service, audience }) => `${service}:${audience}` }));
++ assert.equal(composer.MEDIA_ORIGIN, 'https://openvibe.media');
++ const ctrl = new AbortController();
++ const p = composer.attachments.upload(new Blob(['x']), { signal: ctrl.signal, xhr: fakeXHR({ never: true }) });
++ ctrl.abort();
++ await assert.rejects(p, (err) => err.code === 'sdk.aborted');
++ }],
++
++ ['remove deletes and answers true, false when already gone', async () => {
++ const srv = await stubServer((req, res) => (req.url.endsWith('/missing') ? send(res, 404, { code: 'media.attachment.not_found' }) : res.writeHead(204).end()));
++ const composer = createComposerClient(createClient({ baseUrls: { media: srv.url } }), { mediaBaseUrl: srv.url });
++ assert.equal(await composer.attachments.remove('att_01JAB2C3D4E5F6G7H8J9K0MNPQ'), true);
++ assert.equal(await composer.attachments.remove('missing'), false);
++ assert.deepEqual(srv.requests.map((r) => `${r.method} ${r.url}`), ['DELETE /api/v1/attachments/att_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'DELETE /api/v1/attachments/missing']);
++ await srv.close();
++ }],
++
++ ['prefs get/put name the revision; a stale rev carries the current copy', async () => {
++ const prefs = { rev: 2, send_key: 'enter', tools: [{ id: 'attach', hidden: false }], density: 'comfy', voice: true, snippets: [] };
++ const srv = await stubServer((req, res, raw) => {
++ if (req.method === 'GET') return send(res, 200, prefs);
++ const body = JSON.parse(raw.toString());
++ if (body.rev !== 2) return send(res, 409, { status: 409, code: 'composer.rev_conflict', error: 'moved', current: prefs });
++ return send(res, 200, { ...body, rev: 3 });
++ });
++ const composer = createComposerClient(createClient({ baseUrls: { network: srv.url } }), { networkBaseUrl: srv.url });
++ assert.deepEqual(await composer.composer.prefs.get(), prefs);
++ const saved = await composer.composer.prefs.put({ send_key: 'mod-enter', tools: [], density: 'compact', voice: false, snippets: [] }, 2);
++ assert.equal(saved.rev, 3);
++ assert.equal(saved.send_key, 'mod-enter');
++ const sent = JSON.parse(srv.requests[1].body.toString());
++ assert.equal(srv.requests[1].method, 'PUT');
++ assert.equal(sent.rev, 2);
++ await assert.rejects(composer.composer.prefs.put({ send_key: 'enter', tools: [], density: 'comfy', voice: true, snippets: [] }, 1), (err) => {
++ assert.equal(err.status, 409);
++ assert.equal(err.code, 'composer.rev_conflict');
++ assert.equal(err.problem.current.rev, 2);
++ return true;
++ });
++ assert.throws(() => composer.composer.prefs.put({}, '2'), TypeError);
++ await srv.close();
++ }],
++
++ ['mentions searches with q and kinds and returns the list', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, { mentions: [
++ { kind: 'person', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', label: 'Ana', handle: 'ana', avatar: null, url: 'https://openvibe.network/@ana' },
++ ] }));
++ const composer = createComposerClient(createClient({ baseUrls: { network: srv.url } }));
++ const out = await composer.mentions.search('an a', ['person', 'repo']);
++ assert.equal(out.length, 1);
++ assert.equal(out[0].handle, 'ana');
++ const u = new URL(`http://x${srv.requests[0].url}`);
++ assert.equal(u.pathname, '/api/v1/mentions');
++ assert.equal(u.searchParams.get('q'), 'an a');
++ assert.equal(u.searchParams.get('kinds'), 'person,repo');
++ await srv.close();
++ }],
++]);
ddiff --git a/test/exchange-catalog.test.js b/test/exchange-catalog.test.js
nnew file mode 100644
iindex 0000000..c57accc
---- /dev/null
++++ b/test/exchange-catalog.test.js
@@@ -0,0 +1,21 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { createCatalogSearchClient, ExchangeError } = require('../src/exchange');
++
++(async () => {
++ let url;
++ const client = createCatalogSearchClient({ fetchImpl: async (input, init) => {
++ url = new URL(input);
++ assert.equal(init.headers.Authorization, undefined);
++ return new Response(JSON.stringify({ q: 'fast coder', count: 0, results: [], quality_unavailable: false }), { status: 200 });
++ } });
++ const result = await client.search('fast coder', { max_price: 1 });
++ assert.equal(result.count, 0);
++ assert.equal(url.pathname, '/api/v1/search');
++ assert.equal(url.searchParams.get('max_price'), '1');
++ assert.equal(url.searchParams.get('q'), 'fast coder');
++ await assert.rejects(client.search(null), TypeError);
++ const failing = createCatalogSearchClient({ fetchImpl: async () => new Response(JSON.stringify({ error: 'catalog_unavailable' }), { status: 503 }) });
++ await assert.rejects(failing.search('vision'), (err) => err instanceof ExchangeError && err.status === 503);
++ process.stdout.write('Exchange catalog reader passed\n');
++})().catch((err) => { process.stderr.write(String(err.stack || err)); process.exitCode = 1; });
ddiff --git a/test/feed.test.js b/test/feed.test.js
nnew file mode 100644
iindex 0000000..809d6a9
---- /dev/null
++++ b/test/feed.test.js
@@@ -0,0 +1,12 @@
++'use strict';
++const assert = require('assert');
++const { createFeedClient } = require('../src/feed');
++
++(async () => {
++ const calls = [];
++ const client = createFeedClient({ json: async (input) => { calls.push(input); return { items: [], next_cursor: null }; } }, { communityBaseUrl: 'https://community.test' });
++ const result = await client.list({ kind: 'clip', cursor: 'opaque' });
++ assert.deepStrictEqual(result, { items: [], next_cursor: null });
++ assert.deepStrictEqual(calls[0], { service: 'community', baseUrl: 'https://community.test', audience: 'openvibe.community', path: '/api/v1/feed', query: { kind: 'clip', cursor: 'opaque' }, signal: undefined });
++ console.log('feed.list sends kind and cursor to Community');
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/home-feed.test.js b/test/home-feed.test.js
iindex e95f898..3af7a37 100644
---- a/test/home-feed.test.js
++++ b/test/home-feed.test.js
@@@ -10,19 +10,26 @@ const { createHomeFeedClient } = require('../src/home-feed');
await ov.home.get();
await ov.home.set(layout);
await ov.network.today();
++ await ov.network.popular({ limit: 5 });
++ await ov.network.tryNext({ limit: 3 });
await ov.feed.list({ for: 'me', cursor: 'next' });
await ov.feed.post('Hello', { link: 'https://openvibe.live/' });
await ov.feed.contribute({ id: 'update-1', text: 'New thing', url: 'https://example.test/' });
assert.deepEqual(calls.map((c) => [c.service, c.method || 'GET', c.path]), [
['network', 'GET', '/api/v1/me/home-screen'], ['network', 'PUT', '/api/v1/me/home-screen'],
-- ['network', 'GET', '/api/v1/frame/today'], ['community', 'GET', '/api/v1/pulse'],
++ ['network', 'GET', '/api/v1/frame/today'], ['network', 'GET', '/api/v1/home/popular'],
++ ['network', 'GET', '/api/v1/me/try-next'], ['community', 'GET', '/api/v1/pulse'],
['community', 'POST', '/api/v1/me/status'], ['community', 'POST', '/api/v1/pulse/items'],
]);
assert.deepEqual(calls[1].json, layout);
assert.equal(calls[2].auth, false);
-- assert.deepEqual(calls[3].query, { for: 'me', cursor: 'next' });
-- assert.equal(calls[4].idempotencyKey, false);
-- assert.equal(calls[5].idempotencyKey, false);
++ assert.equal(calls[3].auth, false);
++ assert.deepEqual(calls[3].query, { limit: 5 });
++ assert.equal(calls[4].auth, undefined);
++ assert.deepEqual(calls[4].query, { limit: 3 });
++ assert.deepEqual(calls[5].query, { for: 'me', cursor: 'next' });
++ assert.equal(calls[6].idempotencyKey, false);
++ assert.equal(calls[7].idempotencyKey, false);
const esm = await import('../esm/home-feed.mjs');
assert.equal(typeof esm.createHomeFeedClient, 'function');
const packageEntry = await import('openvibe-sdk/home-feed');
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 7ed14ca..bbed7a0 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -72,7 +72,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, 103); // user-card adds the 103rd subpath after history.
++ assert.equal(subpaths.length, 109); // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/player.test.js b/test/player.test.js
nnew file mode 100644
iindex 0000000..3b799f4
---- /dev/null
++++ b/test/player.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const player = require('../src/player');
++
++run([
++ ['player delegates current and handoff to the shared host', async () => {
++ const old = global.window;
++ try {
++ global.window = { OVOverlay: { current: () => ({ id: 'live', href: 'https://openvibe.live/@test' }), handoffUrl: (url) => `${url}#ovo=card` } };
++ assert.equal(player.current().id, 'live');
++ assert.equal(player.handoff('https://openvibe.games/'), 'https://openvibe.games/#ovo=card');
++ } finally { global.window = old; }
++ }],
++ ['player events can be unsubscribed', async () => {
++ const old = global.window;
++ const listeners = new Map();
++ try {
++ global.window = { addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) };
++ const values = [];
++ const off = player.on((event) => values.push(event.reason));
++ listeners.get('openvibe:player')({ detail: { reason: 'open' } });
++ off();
++ assert.deepEqual(values, ['open']);
++ assert.equal(listeners.size, 0);
++ } finally { global.window = old; }
++ }],
++ ['player ESM subpath exposes handoff', async () => {
++ const esm = await import('../esm/player.mjs');
++ assert.equal(typeof esm.handoff, 'function');
++ }],
++]);
ddiff --git a/test/run.js b/test/run.js
iindex fe20550..ca875c5 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -42,7 +42,9 @@ function skipsIn(output) {
function runOne(file) {
return new Promise((resolve) => {
const started = Date.now();
-- const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ // PGlite's revocation schema can exhaust the default Windows V8 stack during WASM init.
++ const stack = process.platform === 'win32' && file === 'revocations.test.js' ? ['--stack_size=4096'] : [];
++ const child = spawn(process.execPath, [...stack, path.join(__dirname, file)], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
ddiff --git a/test/sso-handoff.test.js b/test/sso-handoff.test.js
nnew file mode 100644
iindex 0000000..960239f
---- /dev/null
++++ b/test/sso-handoff.test.js
@@@ -0,0 +1,159 @@
++'use strict';
++/** openvibe-sdk/sso handoff: mint a ticket for a link, redeem an arrival before first paint. */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const { run, waitFor } = require('./helpers');
++const { createSsoClient, stripHandoffParam, HANDOFF_PARAM } = require('../src/sso');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++const sign = (claims) => { const input = `${b64({ alg: 'RS256', typ: 'JWT', kid: 'k1' })}.${b64(claims)}`; return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), keys.privateKey).toString('base64url')}`; };
++const session = (over = {}) => sign({ sub: 7, id: 7, username: 'ana', iss: ISS, aud: ['openvibe.network', 'openvibe.trade'], iat: now(), exp: now() + 600, ...over });
++
++const TICKET = `ovh_${'t'.repeat(40)}`;
++const USED_TICKET = `ovh_${'u'.repeat(40)}`;
++const BAD_CODE_TICKET = `ovh_${'b'.repeat(40)}`;
++
++// A stub Network: handoff tickets + redeem, the code exchange, the JWKS.
++const net = { tickets: [], redeems: [] };
++const server = http.createServer((req, res) => {
++ let raw = ''; req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const send = (status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); };
++ if (req.url === '/api/.well-known/jwks') return send(200, { keys: [{ ...keys.publicKey.export({ format: 'jwk' }), kid: 'k1', use: 'sig', alg: 'RS256' }] });
++ const body = raw ? JSON.parse(raw) : {};
++ if (req.url === '/api/v1/handoff/tickets') {
++ net.tickets.push({ auth: req.headers.authorization, body });
++ if (req.headers.authorization !== `Bearer ${net.session}`) return send(401, { error: 'Authentication required' });
++ if (body.client_id === 'nope') return send(400, { error: 'invalid_request' });
++ return send(200, { ticket: TICKET, expires_at: new Date(Date.now() + 60000).toISOString(), expires_in: 60, client_id: body.client_id });
++ }
++ if (req.url === '/api/v1/handoff/redeem') {
++ net.redeems.push(body);
++ if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });
++ if (body.ticket === TICKET && body.client_id === 'trade') return send(200, { code: 'handoff-code', expires_in: 300 });
++ if (body.ticket === BAD_CODE_TICKET) return send(200, { code: 'bad-code', expires_in: 300 });
++ return send(400, { error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
++ }
++ if (req.url === '/oauth/token') {
++ if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });
++ if (body.grant_type === 'authorization_code' && body.code === 'handoff-code') {
++ return send(200, { access_token: session(), refresh_token: 'rt_handoff', token_type: 'Bearer' });
++ }
++ return send(400, { error: 'invalid_grant' });
++ }
++ return send(404, {});
++ });
++});
++
++function fakeRes() {
++ const r = { cookies: {}, cleared: [], statusCode: 200 };
++ r.cookie = (k, v, o) => { r.cookies[k] = { v, o }; return r; };
++ r.clearCookie = (k) => { r.cleared.push(k); return r; };
++ r.redirect = (u) => { r.statusCode = 302; r.location = u; return r; };
++ r.status = (s) => { r.statusCode = s; return r; };
++ r.json = (b) => { r.body = b; return r; };
++ r.send = (b) => { r.body = b; return r; };
++ r.type = (t) => { r.contentType = t; return r; };
++ r.set = (k, v) => { (r.headers = r.headers || {})[k] = v; return r; };
++ return r;
++}
++const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
++const silent = { warn() {}, error() {}, info() {} };
++
++let sso, base;
++run([
++ ['handoff param name and strip helper', async () => {
++ assert.equal(HANDOFF_PARAM, 'ov_handoff');
++ assert.equal(stripHandoffParam('/deals?a=1&ov_handoff=ovh_x#h'), '/deals?a=1#h');
++ assert.equal(stripHandoffParam('/deals?ov_handoff=ovh_x'), '/deals');
++ assert.equal(stripHandoffParam('/'), '/');
++ assert.equal(stripHandoffParam(null), '/');
++ }],
++
++ ['mint: 401 without a session, 400 without a destination', async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ base = `http://127.0.0.1:${server.address().port}`;
++ net.session = session();
++ sso = createSsoClient({ site: 'trade', baseUrl: 'https://openvibe.trade', clientId: 'trade', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, log: silent });
++ let res = fakeRes();
++ await sso.handlers.handoffMint({ headers: {}, body: { client_id: 'news', redirect_uri: 'https://openvibe.news/auth/callback' } }, res);
++ assert.equal(res.statusCode, 401, 'no session mints nothing');
++ res = fakeRes();
++ await sso.handlers.handoffMint({ headers: { cookie: cookieHeader({ ov_token: net.session }) }, body: {} }, res);
++ assert.equal(res.statusCode, 400, 'no destination is refused');
++ }],
++
++ ['mint: forwards the person and the destination, returns the ticket', async () => {
++ const res = fakeRes();
++ await sso.handlers.handoffMint({ headers: { cookie: cookieHeader({ ov_token: net.session }) }, body: { client_id: 'news', redirect_uri: 'https://openvibe.news/auth/callback' } }, res);
++ assert.equal(res.statusCode, 200);
++ assert.equal(res.body.ticket, TICKET);
++ assert.equal(res.body.expires_in, 60);
++ assert.equal(net.tickets.at(-1).auth, `Bearer ${net.session}`, 'the person’s own token proves the mint');
++ assert.equal(net.tickets.at(-1).body.client_id, 'news');
++ }],
++
++ ['mint: the Network refusing or silent is a 4xx or 502, never a ticket', async () => {
++ let res = fakeRes();
++ await sso.handlers.handoffMint({ headers: { cookie: cookieHeader({ ov_token: net.session }) }, body: { client_id: 'nope', redirect_uri: 'https://x.example/cb' } }, res);
++ assert.equal(res.statusCode, 400, 'the Network’s refusal is passed through');
++ const down = createSsoClient({ site: 'trade', baseUrl: 'https://openvibe.trade', clientId: 'trade', clientSecret: 'secret', networkUrl: 'http://127.0.0.1:1', networkInternalUrl: 'http://127.0.0.1:1', issuer: ISS, log: silent, publicKey: keys.publicKey.export({ format: 'pem', type: 'spki' }) });
++ res = fakeRes();
++ await down.handlers.handoffMint({ headers: { cookie: cookieHeader({ ov_token: net.session }) }, body: { client_id: 'news', redirect_uri: 'https://openvibe.news/auth/callback' } }, res);
++ assert.equal(res.statusCode, 502, 'an unreachable Network is a 502');
++ }],
++
++ ['arrival: no ticket, a bad ticket, a guest, or a live session all pass through untouched', async () => {
++ const before = net.redeems.length;
++ for (const req of [
++ { method: 'GET', query: {}, headers: {}, url: '/deals' },
++ { method: 'GET', query: { ov_handoff: 'not-a-ticket' }, headers: {}, url: '/deals?ov_handoff=not-a-ticket' },
++ { method: 'GET', query: { ov_handoff: TICKET }, headers: { cookie: cookieHeader({ ov_sso_hint: 'guest' }) }, url: '/' },
++ { method: 'GET', query: { ov_handoff: TICKET }, headers: { cookie: cookieHeader({ ov_token: net.session }) }, url: '/' },
++ { method: 'POST', query: { ov_handoff: TICKET }, headers: {}, url: '/' },
++ ]) {
++ let nexted = false;
++ const res = fakeRes();
++ sso.handoff()(req, res, () => { nexted = true; });
++ await waitFor(() => nexted || res.location !== undefined, { timeoutMs: 30000 });
++ assert.ok(nexted, `passes through: ${req.url} ${JSON.stringify(req.headers)}`);
++ assert.equal(res.location, undefined, 'no redirect when passing through');
++ }
++ assert.equal(net.redeems.length, before, 'no redeem call for any pass-through');
++ }],
++
++ ['arrival: a good ticket signs the browser in and scrubs the address bar', async () => {
++ const res = fakeRes();
++ let nexted = false;
++ sso.handoff()({ method: 'GET', query: { ov_handoff: TICKET }, headers: {}, url: '/deals?a=1&ov_handoff=xx' }, res, () => { nexted = true; });
++ await waitFor(() => nexted || res.location !== undefined, { timeoutMs: 30000 });
++ assert.equal(nexted, false);
++ assert.equal(res.statusCode, 302);
++ assert.equal(res.location, '/deals?a=1', 'the ticket leaves the address bar, the rest stays');
++ assert.ok(res.cookies.ov_token && res.cookies.ov_token.v.length > 100, 'a session is set');
++ assert.equal(res.cookies.ov_sso_hint.v, 'account');
++ assert.equal(net.redeems.at(-1).client_id, 'trade', 'redeemed as this site’s own client');
++ }],
++
++ ['arrival: a refused or broken handoff renders as a guest on a clean URL', async () => {
++ for (const ticket of [USED_TICKET, BAD_CODE_TICKET]) {
++ const res = fakeRes();
++ let nexted = false;
++ sso.handoff()({ method: 'GET', query: { ov_handoff: ticket }, headers: {}, url: `/?ov_handoff=${ticket}` }, res, () => { nexted = true; });
++ await waitFor(() => nexted || res.location !== undefined, { timeoutMs: 30000 });
++ assert.equal(nexted, false);
++ assert.equal(res.location, '/', 'the ticket is scrubbed even on failure');
++ assert.equal(res.cookies.ov_token, undefined, 'no session on failure');
++ }
++ server.close();
++ }],
++ ['the ESM subpath exports the public handoff helpers', async () => {
++ const esm = await import('../esm/sso.mjs');
++ assert.equal(esm.HANDOFF_PARAM, 'ov_handoff');
++ assert.equal(esm.stripHandoffParam('/deals?ov_handoff=ovh_x&sort=top#list'), '/deals?sort=top#list');
++ }],
++]);
ddiff --git a/test/wallet.test.js b/test/wallet.test.js
iindex 524dea9..d467f49 100644
---- a/test/wallet.test.js
++++ b/test/wallet.test.js
@@@ -5,14 +5,33 @@ const { createWalletClient } = require('../src/wallet');
(async () => {
const calls = []; let retry = true;
-- const client = createClient({ token: 'person-token', baseUrls: { billing: 'https://billing.test', inventory: 'https://inventory.test' },
++ const client = createClient({ token: 'person-token', baseUrls: { billing: 'https://billing.test', inventory: 'https://inventory.test', network: 'https://network.test' },
credentials: 'include', retries: 1, retryDelayMs: 1, fetch: async (url, init) => {
calls.push({ url, ...init, json: init.body ? JSON.parse(init.body) : null });
if (retry && url.endsWith('/buy')) { retry = false; return new Response(JSON.stringify({ detail: 'retry' }), { status: 503 }); }
++ if (url.includes('/api/wallet/changes?')) return new Response(JSON.stringify({
++ vibes: { net: 12, parts: { earned: 12, spent: 0, received: 0, sent: 0 }, moves: [{ id: 3, delta: 12, part: 'earned' }], cursor: 3 },
++ opencoins: { net: -4, parts: { earned: 0, spent: -4, received: 0, sent: 0 }, moves: [{ id: 8, delta: -4, part: 'spent' }], cursor: 8 },
++ channel_points: null, state: { vibes: 'open', opencoins: 'open', channel_points: 'needs_streamer' },
++ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} });
const ov = createWalletClient(client);
await ov.wallet.get();
++ const vibesCursor = 'txn_' + '0'.repeat(25) + '1';
++ const changes = await ov.wallet.changes({ since_vibes: vibesCursor, since_opencoins: 7 });
++ assert.strictEqual(changes.vibes.net, 12);
++ assert.strictEqual(changes.opencoins.net, -4);
++ assert.strictEqual(changes.state.channel_points, 'needs_streamer');
++ assert(calls.at(-1).url.includes(`since_vibes=${vibesCursor}`));
++ assert(calls.at(-1).url.includes('since_opencoins=7'));
++ assert(!calls.at(-1).url.includes('subject='));
++ await ov.wallet.changes({ streamer: 9, since_channel_points: 21 });
++ assert(calls.at(-1).url.includes('since_channel_points=21'));
++ await ov.wallet.markRead({ currency: 'channel-points', streamer: 9, cursor: 22 });
++ assert.deepStrictEqual(calls.at(-1).json, { currency: 'channel-points', streamer: 9, cursor: 22 });
++ await ov.wallet.markRead({ currency: 'vibes', cursor: vibesCursor });
++ assert.deepStrictEqual(calls.at(-1).json, { currency: 'vibes', cursor: vibesCursor });
await ov.wallet.topUp({ provider: 'stripe', amount_cents: 1000, idempotency_key: 'topup-test-01', subject: 'ignored' });
await ov.vibes.buy({ provider: 'stripe', vibes: 100, idempotency_key: 'vibes-test-01', amount_cents: 1 });
await ov.store.list();
@@@ -34,5 +53,8 @@ const { createWalletClient } = require('../src/wallet');
assert.throws(() => ov.store.buy(id, { currency: 'USD', idempotency_key: 'safe-key-01' }));
assert.throws(() => ov.vibes.buy({ provider: 'stripe', vibes: 1.5, idempotency_key: 'safe-key-01' }));
assert.throws(() => ov.wallet.topUp({ provider: 'stripe', amount_cents: 1000 }));
++ assert.throws(() => ov.wallet.changes({ streamer: -1 }));
++ assert.throws(() => ov.wallet.changes({ since_vibes: 'someone-else' }));
++ assert.throws(() => ov.wallet.markRead({ currency: 'channel-points', cursor: 22 }));
console.log('wallet facade: routes, personal bearer, no cookies, stable retry keys and no price/subject injection');
})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/types/actor.d.ts b/types/actor.d.ts
iindex 412e636..6daa19d 100644
---- a/types/actor.d.ts
++++ b/types/actor.d.ts
@@@ -44,6 +44,7 @@ export interface ActorClient {
list(opts?: Opts): Promise<{ agents: ActorJson[] }>;
create(agent: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;
get(id: string, opts?: Opts): Promise<ActorJson>;
++ sheet(id: string, opts?: Opts): Promise<import('./agent-sheet').AgentSheet>;
update(id: string, patch: Record<string, unknown>, opts?: Opts): Promise<ActorJson>;
remove(id: string, opts?: Opts): Promise<ActorJson>;
run(id: string, req?: { input?: string }, opts?: Opts): Promise<ActorJson>;
ddiff --git a/types/agent-sheet.d.ts b/types/agent-sheet.d.ts
nnew file mode 100644
iindex 0000000..90ef537
---- /dev/null
++++ b/types/agent-sheet.d.ts
@@@ -0,0 +1,24 @@
++import type { OpenVibeClient } from './core';
++export interface AgentSheet {
++ agent: { id: string; name: string; face: string | null; state: string; [key: string]: unknown };
++ now: Record<string, unknown> | null;
++ work: Array<Record<string, unknown>>;
++ asks: Array<Record<string, unknown>>;
++ asks_empty: string | null;
++ computer: Record<string, unknown> | null;
++ schedules: Array<Record<string, unknown>>;
++ memory: Array<Record<string, unknown>>;
++ activity: Array<Record<string, unknown>>;
++}
++export interface AgentSheetClient {
++ sheet(agentId: string, options?: { signal?: AbortSignal }): Promise<AgentSheet>;
++ codesSheet(harnessId: string, options?: { signal?: AbortSignal }): Promise<AgentSheet>;
++ pause(agentId: string, options?: { signal?: AbortSignal }): Promise<Record<string, unknown>>;
++ resume(agentId: string, options?: { signal?: AbortSignal }): Promise<Record<string, unknown>>;
++ memory: {
++ add(agentId: string, text: string, options?: { signal?: AbortSignal }): Promise<Record<string, unknown>>;
++ update(agentId: string, memoryId: string, text: string, options?: { signal?: AbortSignal }): Promise<Record<string, unknown>>;
++ remove(agentId: string, memoryId: string, options?: { signal?: AbortSignal }): Promise<unknown>;
++ };
++}
++export function createAgentSheetClient(client: OpenVibeClient, options?: { baseUrl?: string }): AgentSheetClient;
ddiff --git a/types/agents.d.ts b/types/agents.d.ts
nnew file mode 100644
iindex 0000000..cf3bc3c
---- /dev/null
++++ b/types/agents.d.ts
@@@ -0,0 +1,8 @@
++/** AG-UI 1.0 event stream and agent.run@1 snapshot. */
++export interface AgentSource { url: string; title: string; site: string }
++export interface AgentUiEvent { type: string; timestamp: string; threadId?: string; runId?: string; metadata?: { ov?: { title?: string; kind?: string; sources?: AgentSource[]; attachments?: unknown[]; cost_usd?: number | null; needs_you?: object | null; product?: string; ref?: string | null } }; [field: string]: unknown }
++export interface AgentRun { id: string; product: string; title: string; status: 'queued'|'running'|'waiting'|'needs_you'|'succeeded'|'failed'|'stopped'|'cancelled'; plan: Array<{title:string;status:string}>; steps: Array<Record<string, unknown>>; sources: AgentSource[]; result: Record<string, unknown>|null; started_at: string; ended_at: string|null; cost_usd: number|null }
++export interface AgentApproval { id?: string; action?: string; [field: string]: unknown }
++export interface AgentEvent { id: string|null; event: string|null; data: AgentUiEvent|null }
++export interface AgentsClient { run(id: string, options?: {signal?: AbortSignal}): Promise<AgentRun|null>; events(id: string, options?: {lastEventId?: string|number; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number}): AsyncIterable<AgentEvent>; post(id: string, approval: AgentApproval, options?: {signal?: AbortSignal}): Promise<unknown> }
++export function createAgentsClient(client: unknown, options?: {baseUrl?: string; service?: string; audience?: string}): AgentsClient;
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 5b62983..a89bbf4 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -1,15 +1,19 @@
import * as userCard from './user-card';
++import * as player from './player';
++import * as agentSheet from './agent-sheet';
/** browser/openvibe-sdk.mjs: one self-contained ES module with the browser-safe subpaths (same shape as the package's browser entry). */
import * as auth from './auth-browser';
import * as registry from './registry';
import * as modules from './modules';
import * as realtime from './realtime';
import * as media from './media';
++import * as composer from './composer';
import * as cam from './cam';
import * as mediaUpload from './media-upload';
import * as ui from './ui';
import * as community from './community';
import * as homeFeed from './home-feed';
++import * as feed from './feed';
import * as bot from './bot';
import * as search from './search';
import * as bench from './bench';
@@@ -22,6 +26,7 @@ import * as chat from './chat';
import * as ai from './ai';
import * as credentials from './credentials';
import * as jobs from './jobs';
++import * as agents from './agents';
import * as tools from './tools';
import * as projects from './projects';
import * as geo from './geo';
@@@ -33,16 +38,19 @@ import * as core from './core';
export const createWalletClient: typeof import('./wallet').createWalletClient;
export * from './core';
++export * as agentSheet from './agent-sheet';
export * as auth from './auth-browser';
export * as registry from './registry';
export * as modules from './modules';
export * as realtime from './realtime';
export * as media from './media';
++export * as composer from './composer';
export * as cam from './cam';
export * as mediaUpload from './media-upload';
export * as ui from './ui';
export * as community from './community';
export * as homeFeed from './home-feed';
++export * as feed from './feed';
export * as bot from './bot';
export * as search from './search';
export * as bench from './bench';
@@@ -55,6 +63,7 @@ export * as chat from './chat';
export * as ai from './ai';
export * as credentials from './credentials';
export * as jobs from './jobs';
++export * as agents from './agents';
export * as tools from './tools';
export * as projects from './projects';
export * as geo from './geo';
@@@ -64,11 +73,13 @@ export * as trust from './trust';
export * as levels from './levels';
export * as userCard from './user-card';
++export * as player from './player';
declare const sdk: typeof core & {
createWalletClient: typeof createWalletClient;
userCard: typeof userCard;
++ player: typeof player;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; community: typeof community; homeFeed: typeof homeFeed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
++ media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
};
export default sdk;
ddiff --git a/types/chat.d.ts b/types/chat.d.ts
iindex 1c36f58..a099184 100644
---- a/types/chat.d.ts
++++ b/types/chat.d.ts
@@@ -93,11 +93,30 @@ export interface RoomMemory {
can_edit: boolean;
me: string | null;
}
++/** chat.person-memory-result@1. Private fields appear only for the person and channel owner. */
++export interface PersonMemoryResult {
++ username: string;
++ level: 'day' | 'week' | 'month';
++ channel: string | null;
++ opted_out: boolean;
++ enabled: boolean;
++ entries: {
++ level: 'day' | 'week' | 'month'; period_start: string; channel_user_id: number;
++ summary: string; topics: string[];
++ highlights: { detail: string; stream_id: number | null; at: string | null }[];
++ message_count: number; tone?: string; catchphrases?: string[]; people?: string[];
++ }[];
++}
export interface ChatClient {
/** The typed address previews a message carries (chat.embed@1): [] when it has none. */
embeds(message?: { embeds?: unknown } | null): ChatEmbed[];
history: ChatHistoryHelper;
++ people: {
++ memory(username: string, query?: { channel?: string; level?: 'day' | 'week' | 'month' }, opts?: Opts): Promise<PersonMemoryResult>;
++ forget(username: string, opts?: Opts): Promise<{ deleted: boolean; opted_out: boolean }>;
++ preferences(username: string, optedOut: boolean, opts?: Opts): Promise<{ opted_out: boolean }>;
++ };
/** Ask Vibe using Auto, one catalogue model, or two to three models side by side. */
ask(input: { message: string; models?: string[]; slug?: string; fresh?: boolean; system_prompt?: string; temperature?: number }, opts?: Opts): Promise<{ chat: Json; message: Json }>;
pick(messageId: number, model: string, opts?: Opts): Promise<{ chat: Json; model: string }>;
ddiff --git a/types/codes.d.ts b/types/codes.d.ts
nnew file mode 100644
iindex 0000000..dbbf2f8
---- /dev/null
++++ b/types/codes.d.ts
@@@ -0,0 +1,20 @@
++/** openvibe-sdk/codes: OpenVibe.Codes — the person's coding tasks as the Dock's Code tab lists, starts and stops them. */
++type Opts = { signal?: AbortSignal };
++type CodesJson = Record<string, any>;
++export type CodesTaskMode = 'create' | 'openvibe' | 'repo';
++export type CodesTaskStatus = 'queued' | 'running' | 'needs-you' | 'done' | 'failed' | 'stopped';
++export interface CodesTaskSummary {
++ id: string; title: string; status: CodesTaskStatus; mode: CodesTaskMode; repo: string;
++ started_at: string; updated_at: string; steps: number; url: string; preview_url: string | null; pr_url: string | null;
++}
++export interface CodesTaskCreate {
++ text: string; mode: CodesTaskMode; repo?: string; attachments?: string[];
++}
++export interface CodesClient {
++ tasks: {
++ list(opts?: Opts & { limit?: number }): Promise<{ tasks: CodesTaskSummary[] }>;
++ create(req: CodesTaskCreate, opts?: Opts): Promise<{ id: string | null; url: string }>;
++ stop(id: string, opts?: Opts): Promise<{ ok: true }>;
++ };
++}
++export declare function createCodesClient(client: CodesJson, opts?: { baseUrl?: string }): { tasks: CodesClient['tasks'] };
ddiff --git a/types/community.d.ts b/types/community.d.ts
iindex 5c74ab1..412036b 100644
---- a/types/community.d.ts
++++ b/types/community.d.ts
@@@ -12,7 +12,7 @@ export type CallOptions = ActingOptions & { signal?: AbortSignal };
export interface CommentQuery { after?: string | number; sort?: 'old' | 'new'; limit?: number; parent?: string | number; }
export type CommentAttachment = AiFile & { url?: string };
--export interface Comment { id: number; thread_id: number | string; parent_id: number | null; message: string | null; reply_count?: number | null; replies?: Comment[]; attachments?: CommentAttachment[]; [field: string]: unknown; }
++export interface Comment { id: number; thread_id: number | string; parent_id: number | null; reply_to_id: number | null; reply_to: { id: number; display_name: string } | null; message: string | null; reply_count?: number | null; replies?: Comment[]; attachments?: CommentAttachment[]; [field: string]: unknown; }
export interface CommentPage { thread: Record<string, unknown>; comments: Comment[]; next_cursor: string | null; viewer: Record<string, boolean>; }
export interface CommentsApi {
resolve(ref: EntityRef, opts?: CallOptions): Promise<{ thread: Record<string, unknown>; created: boolean }>;
@@@ -60,7 +60,7 @@ export interface FeedItem {
site: string;
site_name: string;
actor: { name: string | null; avatar: string | null } | null;
-- media: { thumb: string; ratio: number; length?: number } | null;
++ media: { thumb: string; preview?: string; ratio: number; length?: number } | null;
counts: { views?: number; likes?: number; comments?: number };
/** True while on air; false on an ended stream. Absent for other kinds. */
live_now?: boolean;
@@@ -76,10 +76,14 @@ export interface PostsApi {
export interface CommunityClient {
pastes: PastesApi;
comments: CommentsApi;
-- feed(query?: FeedQuery, opts?: CallOptions): Promise<{ items: FeedItem[]; next_cursor: string | null }>;
++ feed: FeedReader;
posts: PostsApi;
as(who: string | ActingOptions): CommunityClient;
headers(): Record<string, string>;
}
++export interface FeedReader {
++ (query?: FeedQuery, opts?: CallOptions): Promise<{ items: FeedItem[]; next_cursor: string | null }>;
++ list(query?: Pick<FeedQuery, 'kind' | 'cursor'>, opts?: CallOptions): Promise<{ items: FeedItem[]; next_cursor: string | null }>;
++}
export declare function createCommunityClient(client: OpenVibeClient, defaults?: ActingOptions & { baseUrl?: string }): CommunityClient;
export declare function actingHeaders(opts?: ActingOptions): Record<string, string>;
ddiff --git a/types/composer.d.ts b/types/composer.d.ts
nnew file mode 100644
iindex 0000000..bbb6a92
---- /dev/null
++++ b/types/composer.d.ts
@@@ -0,0 +1,75 @@
++import type { OpenVibeClient } from './core';
++
++/** composer.attachment@1: one file a person attached to a message. */
++export interface ComposerAttachment {
++ id: string;
++ kind: 'image' | 'video' | 'audio' | 'file' | 'code';
++ name: string;
++ size: number;
++ mime: string;
++ url: string;
++ thumb?: string | null;
++ w?: number | null;
++ h?: number | null;
++ expires_at: string;
++}
++
++/** composer.prefs@1: a person's composer settings on every site. */
++export interface ComposerPrefs {
++ /** The revision that was read; a PUT names it, and a moved one is 409 composer.rev_conflict. */
++ rev: number;
++ send_key: 'enter' | 'mod-enter';
++ tools: Array<{ id: string; hidden: boolean }>;
++ density: 'comfy' | 'compact';
++ voice: boolean;
++ snippets: Array<{ trigger: string; label: string; text: string }>;
++}
++
++/** The document a PUT sends: the fields, with rev = the revision it read (0 for a new person). */
++export type ComposerPrefsInput = Omit<ComposerPrefs, 'rev'>;
++
++/** composer.mention@1: one entry of the composer's @ menu. */
++export interface ComposerMention {
++ kind: 'person' | 'repo' | 'site' | 'file';
++ id: string;
++ label: string;
++ handle?: string;
++ avatar?: string | null;
++ url: string;
++}
++
++export type ComposerFile = Blob | ArrayBuffer | ArrayBufferView | string;
++
++export interface UploadOptions {
++ onProgress?: (p: { loaded: number; total: number; lengthComputable: boolean }) => void;
++ signal?: AbortSignal;
++ filename?: string;
++ contentType?: string;
++ /** An XMLHttpRequest implementation, for a non-browser shell or a test. */
++ xhr?: new () => XMLHttpRequest;
++}
++
++export interface ComposerClient {
++ attachments: {
++ upload(file: ComposerFile, opts?: UploadOptions): Promise<ComposerAttachment>;
++ /** true when it was deleted, false when it was already gone. */
++ remove(id: string): Promise<boolean>;
++ };
++ composer: {
++ prefs: {
++ get(): Promise<ComposerPrefs>;
++ /** A stale rev rejects with an OpenVibeError: status 409, code composer.rev_conflict, err.problem.current. */
++ put(prefs: ComposerPrefsInput, rev: number): Promise<ComposerPrefs>;
++ };
++ };
++ mentions: {
++ search(q?: string, kinds?: Array<'person' | 'repo' | 'site' | 'file'> | string): Promise<ComposerMention[]>;
++ };
++ MEDIA_ORIGIN: string;
++ NETWORK_ORIGIN: string;
++}
++
++export declare function createComposerClient(client: OpenVibeClient, opts?: { mediaBaseUrl?: string; networkBaseUrl?: string }): ComposerClient;
++export declare const MEDIA_ORIGIN: string;
++export declare const NETWORK_ORIGIN: string;
++export declare const ATTACHMENT_MAX: number;
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex 9a1d2b4..b3e5289 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -1969,9 +1969,21 @@ export interface ServiceManifest {
statusApi?: string;
};
/**
-- * The room's launcher group in OpenVibe.Shared rooms.js and OpenVibe.Network frame/sites.js; optional so older manifests retain their meaning.
++ * The site's launcher group: the eight current groups are watch, talk, play, read, shop, tools, make and build (OpenVibe.Network frame/sites.js GROUPS), whose order and labels the menu, the footer and the home pages show (OpenVibe.Shared rooms.js repeats the ids for its rooms). The pre-0.400.0 ids streams, games, answers and people stay accepted for older manifests.
*/
-- neighbourhood?: "streams" | "games" | "answers" | "make" | "shop" | "people" | "build";
++ neighbourhood?:
++ | "watch"
++ | "talk"
++ | "play"
++ | "read"
++ | "shop"
++ | "tools"
++ | "make"
++ | "build"
++ | "streams"
++ | "games"
++ | "answers"
++ | "people";
/**
* This site's own items on the network's footer strip, read with the rest of it at openvibe.network GET /api/frame/online?site=<id>: a label, an icon from the shared glyph set, an https link on an OpenVibe domain (any openvibe.* host), and either a count the site reports or a fixed line. Network checks every field again before it reaches a page.
*
ddiff --git a/types/exchange.d.ts b/types/exchange.d.ts
iindex 3bd7310..926a3f6 100644
---- a/types/exchange.d.ts
++++ b/types/exchange.d.ts
@@@ -333,8 +333,12 @@ export type ExchangeEarnings = {
};
export type ExchangeKey = { id: string; name: string; prefix: string; monthly_cap_usd: number; allowed_models: string[]; created_at: string; revoked_at: string | null };
--export type ExchangeSearchOptions = { engine?: string; limit?: number; license?: string; commercial?: boolean; format?: string; orientation?: string; size?: string; style?: 'line' | 'solid' | 'any'; set?: string; signal?: AbortSignal };
--export type ExchangeSearchResult = { results: Array<{ title: string; url: string; snippet: string; engine: string; published: string | null; thumbnail?: string | null; license?: string | null; author?: string | null; source_page?: string | null; attribution?: string | null }>; receipt: { engine: string; cost_usd: number; time_ms: number } };
++export type ExchangeSearchOptions = { engine?: string; limit?: number; license?: string; commercial?: boolean; format?: string; orientation?: string; size?: 'small' | 'medium' | 'large'; color?: 'black_and_white' | 'black' | 'white' | 'yellow' | 'orange' | 'red' | 'purple' | 'magenta' | 'green' | 'teal' | 'blue'; type?: 'icon' | 'photo' | 'illustration' | 'vector'; after?: string; safe_search?: 'strict'; style?: 'line' | 'solid' | 'any'; set?: string; signal?: AbortSignal };
++export interface CatalogSearchFilters { provider?: string; capability?: string; min_price?: number; max_price?: number; max_speed_ms?: number; sort?: 'relevance' | 'price' | 'speed' | 'quality'; rating_kind?: string; }
++export interface CatalogModelResult { model: string; name: string; family: string | null; capabilities: Record<string, boolean>; provider: string; price: { in: number; out: number; cached?: number; per?: string; currency?: string } | null; speed: { p50_ms?: number; p95_ms?: number; tokens_per_second?: number } | null; quality: { elo: number; games: number; kind: string } | null; offers: number; task_match: string | null; score: number; }
++export interface CatalogSearchResponse { q: string; count: number; results: CatalogModelResult[]; quality_unavailable: boolean; }
++export declare function createCatalogSearchClient(options?: { baseUrl?: string; fetchImpl?: typeof fetch }): { search(q: string, filters?: CatalogSearchFilters, options?: { signal?: AbortSignal }): Promise<CatalogSearchResponse> };
++export type ExchangeSearchResult = { results: Array<{ title: string; url: string; snippet: string; engine: string; published: string | null; indexed_on?: string | null; type?: string | null; thumbnail?: string | null; license?: string | null; license_url?: string | null; author?: string | null; author_url?: string | null; source_page?: string | null; attribution?: string | null; width?: number | null; height?: number | null }>; receipt: { engine: string; cost_usd: number; time_ms: number } };
export type ExchangeClient = {
keys: {
list(opts?: { signal?: AbortSignal }): Promise<{ keys: ExchangeKey[] }>;
ddiff --git a/types/feed.d.ts b/types/feed.d.ts
nnew file mode 100644
iindex 0000000..aa1354d
---- /dev/null
++++ b/types/feed.d.ts
@@@ -0,0 +1,7 @@
++import type { CallOptions } from './core';
++import type { FeedItem, FeedKind } from './community';
++
++export interface FeedClient {
++ list(query?: { kind?: FeedKind; cursor?: string }, opts?: CallOptions): Promise<{ items: FeedItem[]; next_cursor: string | null }>;
++}
++export declare function createFeedClient(client: import('./core').OpenVibeClient, options?: { communityBaseUrl?: string }): FeedClient;
ddiff --git a/types/home-feed.d.ts b/types/home-feed.d.ts
iindex 1597a12..0affc87 100644
---- a/types/home-feed.d.ts
++++ b/types/home-feed.d.ts
@@@ -4,9 +4,45 @@ export interface HomeLayout { home: string[]; folders: Array<{ id: string; name:
export interface TodayPoint { day: string; people: number | null; streams: number | null; posts: number | null; new_videos: number | null; }
export interface Today { day: string; people_today: number | null; streams_today: number | null; posts_today: number | null; new_videos_today: number | null; series: TodayPoint[]; updated_at: string; }
export interface FeedItem { id: number; source: { service: string; type: string; id: string }; title: string; url: string; label: string | null; via?: string; picture_url?: string; actor: Record<string, unknown> | null; occurred_at: string | null; }
++/** One destination in the signed-out "things people do now" list and in a person's Try next: a public label, an
++ * https address, an icon id and the one-line reason it is there, with the Report numbers behind them when known. */
++export interface PopularItem {
++ id: string;
++ kind: 'site' | 'tool';
++ name: string;
++ url: string;
++ icon: string;
++ reason: string;
++ score?: number;
++ opens_24h?: number | null;
++ opens_7d?: number | null;
++ opens_prev_7d?: number | null;
++ growth_7d_pct?: number | null;
++ uses_24h?: number | null;
++ uses_7d?: number | null;
++}
++export interface Popular {
++ source: 'report' | 'fallback';
++ reason: string;
++ generated_at: string | null;
++ fetched_at: string | null;
++ stale: boolean;
++ items: PopularItem[];
++ error?: string;
++}
++export interface TryNext {
++ source: 'report' | 'fallback';
++ basis: 'co-usage' | 'popular' | 'starter';
++ generated_at: string;
++ items: PopularItem[];
++}
export interface HomeFeedClient {
home: { get(opts?: { signal?: AbortSignal }): Promise<HomeLayout>; set(layout: HomeLayout, opts?: { signal?: AbortSignal }): Promise<HomeLayout> };
-- network: { today(opts?: { signal?: AbortSignal }): Promise<Today> };
++ network: {
++ today(opts?: { signal?: AbortSignal }): Promise<Today>;
++ popular(opts?: { limit?: number; signal?: AbortSignal }): Promise<Popular>;
++ tryNext(opts?: { limit?: number; signal?: AbortSignal }): Promise<TryNext>;
++ };
feed: {
list(opts?: { for?: 'me'; cursor?: string; signal?: AbortSignal }): Promise<{ items: FeedItem[]; next_cursor: string | null }>;
post(text: string, opts?: { link?: string; signal?: AbortSignal }): Promise<{ id: number; post_id: number; text: string; url: string; created_at: string }>;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 53f179b..bc2e672 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -15,6 +15,7 @@ export * as mediaUpload from './media-upload';
export * as ui from './ui';
export * as community from './community';
export * as homeFeed from './home-feed';
++export * as feed from './feed';
export * as bot from './bot';
export * as search from './search';
export * as bench from './bench';
@@@ -27,6 +28,7 @@ export * as chatApps from './chat-apps';
export * as ai from './ai';
export * as credentials from './credentials';
export * as jobs from './jobs';
++export * as agents from './agents';
export * as tools from './tools';
export * as projects from './projects';
export * as resources from './resources';
@@@ -48,6 +50,7 @@ export * as host from './host';
export * as news from './news';
export * as food from './food';
export * as actor from './actor';
++export * as codes from './codes';
export * as run from './run';
export * as video from './video';
export * as pics from './pics';
@@@ -69,5 +72,7 @@ export * as locker from './locker';
export const createWalletClient: typeof import('./wallet').createWalletClient;
export * as levels from './levels';
--
++export * as composer from './composer';
export * as userCard from './user-card';
++export * as player from './player';
++export * as agentSheet from './agent-sheet';
ddiff --git a/types/player.d.ts b/types/player.d.ts
nnew file mode 100644
iindex 0000000..4ac3e97
---- /dev/null
++++ b/types/player.d.ts
@@@ -0,0 +1,17 @@
++export interface CurrentPlayer {
++ id: string;
++ src: string;
++ title: string;
++ href?: string;
++ live: boolean;
++ sound: boolean;
++ playing?: boolean;
++ side?: { src: string; title: string };
++}
++export interface PlayerEvent { reason: 'open' | 'close' | 'declare' | 'handoff' | 'state'; card: CurrentPlayer | null }
++/** The active portable live stream, if one exists on this page. */
++export function current(): CurrentPlayer | null;
++/** Add the active player to an OpenVibe destination URL for same-tab navigation. */
++export function handoff(url: string): string;
++/** Subscribe to changes. Returns a function that removes the listener. */
++export function on(listener: (event: PlayerEvent) => void): () => void;
ddiff --git a/types/sso.d.ts b/types/sso.d.ts
iindex 7f3bef6..41852fb 100644
---- a/types/sso.d.ts
++++ b/types/sso.d.ts
@@@ -41,9 +41,11 @@ export interface SsoClientOptions {
export type SsoUser = Omit<UserTokenClaims, 'iat' | 'exp' | 'aud' | 'iss' | 'nbf' | 'jti'> & Record<string, unknown>;
export interface SsoClient {
-- /** GET /login /callback /logout /me /health, POST /fedcm /refresh; mount at /auth. */
++ /** GET /login /callback /logout /me /health, POST /fedcm /refresh /handoff; mount at /auth. */
router(express: any): any;
-- handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler; health: Handler };
++ handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler; health: Handler; handoffMint: Handler };
++ /** Redeems a single-use cross-site ticket before the page renders; never replaces an existing session. */
++ handoff(): (req: Req, res: Res, next: Next) => void;
/** Sets req.user and req.token when a valid session is presented. */
optionalAuth(): (req: Req, res: Res, next: Next) => void;
/** 401 JSON without a valid session. */
@@@ -64,6 +66,8 @@ export declare function createSsoClient(opts: SsoClientOptions): SsoClient;
/** A same-site path, this site's https origin or the Network's; anything else (control characters, backslashes) is '/'. */
export declare function sanitizeNext(next: unknown, opts?: { baseUrl?: string; networkUrl?: string }): string;
export declare function withParam(target: string, key: string, value: string): string;
++/** Remove the one-time handoff ticket from a path+query URL, preserving all other parameters and the fragment. */
++export declare function stripHandoffParam(target: string): string;
/** The self-contained page a sign-in that cannot finish shows (text is escaped). */
export declare function failurePage(o: { siteName: string; title: string; text: string; retryHref: string; backHref: string }): string;
/** The payload of a JWT, unverified; null when it does not parse. */
@@@ -72,3 +76,4 @@ export declare function fedcmNonceMatches(token: string, nonce: string): boolean
export declare function claimsToUser(claims: Record<string, any> | null): SsoUser | null;
export declare function parseCookies(req: { headers?: Record<string, any> }): Record<string, string>;
export declare const COOKIES: Readonly<{ access: string; refresh: string; hint: string }>;
++export declare const HANDOFF_PARAM: 'ov_handoff';
ddiff --git a/types/wallet.d.ts b/types/wallet.d.ts
iindex 5f51057..91b89e7 100644
---- a/types/wallet.d.ts
++++ b/types/wallet.d.ts
@@@ -10,9 +10,14 @@ export interface Wallet {
export interface StoreItem { id: string; name: string; description: string; issuer: string; prices: { coins?: number; vibes?: number }; preview: { emoji?: string; token?: string; media_id?: string }; kind: string; slots: string[]; surfaces: Array<{ surface: string; renderer?: string }>; owned: boolean; available: boolean; }
export interface Purchase { id: string; subject: string; item_id: string; name: string; issuer: string; currency: 'coins' | 'vibes'; amount: number; status: 'pending' | 'fulfilled'; instance_id: string | null; payment_id: string | null; }
export interface Checkout { intent: Record<string, unknown>; checkout_url: string | null; }
++export type BalanceCurrency = 'vibes' | 'opencoins' | 'channel-points';
++export type BalancePart = 'earned' | 'spent' | 'received' | 'sent';
++export interface BalanceMove { id: string | number; at: string; delta: number; part: BalancePart; label: string; detail?: string; reason?: string | null; reason_key?: string; subject?: { name: string; url: string } | null; streamer_id?: number; }
++export interface BalanceChanges { currency: BalanceCurrency; balance: number | Record<string, number> | null; since: string | number | null; moves: BalanceMove[]; parts: Record<BalancePart, number>; net: number; cursor: string | number | null; next_since: string | number | null; read_cursor: string | number | null; streamer?: number; truncated?: boolean; }
++export interface WalletChanges { vibes: BalanceChanges | null; opencoins: BalanceChanges | null; channel_points: BalanceChanges | null; state: { vibes: 'open' | 'unavailable' | 'not_authoritative'; opencoins: 'open' | 'unavailable'; channel_points: 'open' | 'unavailable' | 'unlinked' | 'needs_streamer' }; }
type Options = { signal?: AbortSignal };
--export function createWalletClient(client: OpenVibeClient, options?: { billingUrl?: string; inventoryUrl?: string }): {
-- wallet: { get(options?: Options): Promise<Wallet>; topUp(input: { provider: string; amount_cents: number; idempotency_key: string }, options?: Options): Promise<Checkout> };
++export function createWalletClient(client: OpenVibeClient, options?: { billingUrl?: string; inventoryUrl?: string; networkUrl?: string }): {
++ wallet: { get(options?: Options): Promise<Wallet>; changes(options?: Options & { since_vibes?: string; since_opencoins?: number; since_channel_points?: number; streamer?: number; limit?: number }): Promise<WalletChanges>; markRead(input: { currency: BalanceCurrency; cursor: string | number; streamer?: number }, options?: Options): Promise<{ currency: BalanceCurrency; read_cursor: string | number; streamer?: number }>; topUp(input: { provider: string; amount_cents: number; idempotency_key: string }, options?: Options): Promise<Checkout> };
vibes: { buy(input: { provider: string; vibes: number; idempotency_key: string }, options?: Options): Promise<Checkout> };
store: { list(options?: Options): Promise<{ items: StoreItem[] }>; buy(id: string, input: { currency: 'coins' | 'vibes'; idempotency_key: string }, options?: Options): Promise<Purchase>; submit(id: string, prices: { coins?: number; vibes?: number }, options?: Options): Promise<{ definition_id: string; issuer: string; prices: { coins?: number; vibes?: number }; status: 'in_review' }> };
items: { list(options?: Options & { cursor?: string; limit?: number; kind?: string }): Promise<{ subject: string; instances: import('./inventory').Instance[]; definitions: Record<string, import('./inventory').Definition>; next_cursor: string | null }>; equip(input: { kind: string; slot: string; instance_id: string | null }, options?: Options): Promise<import('./inventory').EquippedSet> };